diff --git a/lana/package.json b/lana/package.json index 2a9ff073..13d89921 100644 --- a/lana/package.json +++ b/lana/package.json @@ -185,7 +185,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..f7c03ded 100644 --- a/lana/src/Context.ts +++ b/lana/src/Context.ts @@ -1,43 +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); - if (!path.length) { - this.display.showErrorMessage(`Type '${symbol}' was not found in workspace`); + async findSymbol(apexSymbol: ApexSymbol): Promise { + const path = await this.workspaceManager.findSymbol(apexSymbol); + + if (!path) { + this.display.showErrorMessage(`Type '${apexSymbol.fullSymbol}' was not found in workspace`); } + return path; } } diff --git a/lana/src/__mocks__/vscode.ts b/lana/src/__mocks__/vscode.ts new file mode 100644 index 00000000..552eff3a --- /dev/null +++ b/lana/src/__mocks__/vscode.ts @@ -0,0 +1,32 @@ +/* + * 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: [], + asRelativePath: jest.fn((uri: { fsPath: string } | string) => + typeof uri === 'string' ? uri : uri.fsPath, + ), +}; + +export const window = { + showInformationMessage: jest.fn(), + showErrorMessage: jest.fn(), + showWarningMessage: jest.fn(), + createOutputChannel: jest.fn(() => ({ + appendLine: jest.fn(), + show: jest.fn(), + })), +}; diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index b37f01aa..ab5eaa85 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,48 +21,24 @@ export class OpenFileInPackage { return; } - const parts = symbolName.split('.'); - const fileName = parts[0]?.trim(); + await context.workspaceManager.initialiseWorkspaceProjectInfo(); + const apexSymbol = parseSymbol(symbolName, context.workspaceManager.getAllProjects()); - const paths = await context.findSymbol(fileName as string); - if (!paths.length) { - return; - } + const uri = await context.findSymbol(apexSymbol); - const matchingWs = context.workspaces.filter((ws) => { - const found = paths.findIndex((p) => p.startsWith(ws.path())); - if (found > -1) { - return ws; - } - }); - - 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 (!uri) { return; } - const wsPathTrimmed = wsPath.description.trim(); - const path = - paths.find((e) => { - return e.startsWith(wsPathTrimmed + sep); - }) || ''; - - const uri = Uri.file(path); 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.fullSymbol}'`, ); } const zeroIndexedLineNumber = symbolLocation.line - 1; @@ -77,6 +52,6 @@ export class OpenFileInPackage { selection: new Selection(pos, pos), }; - context.display.showFile(path, options); + commands.executeCommand('vscode.open', uri, 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 6ce450b2..4beae908 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,61 +26,71 @@ 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 (currentRoot.name !== apexSymbol.outerClass) { + currentRoot = findClassNode(currentRoot, apexSymbol.outerClass); } - if (!symbols.length) { + 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 qualifierString = apexSymbol.namespace + ? `${apexSymbol.namespace}|${apexSymbol.outerClass}` + : apexSymbol.outerClass; return root.children?.find( (child) => - child.name === methodName && + child.name === apexSymbol.method && child.nature === 'Method' && - (paramStr === undefined || (child as ApexMethodNode).params === paramStr), + (apexSymbol.parameters === '' || + matchesUnqualified( + qualifierString, + (child as ApexMethodNode).params, + apexSymbol.parameters, + )), ) 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) { diff --git a/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts b/lana/src/salesforce/__tests__/ApexSymbolLocator.test.ts index d530e262..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'); @@ -31,19 +49,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, + }, ], }; @@ -70,48 +99,111 @@ 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)']); - expect(result.line).toBe(6); + 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()']); - expect(result.line).toBe(5); + 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'); }); }); + + describe('fuzzy parameter matching', () => { + let root: ApexNode; + + beforeEach(() => { + root = parseApex(''); + }); + + it('should find method when fully qualified inner class passed', () => { + 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, + 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, + createSymbol({ + outerClass: 'MyClass', + method: 'baz', + parameters: 'MyClass.Inner, InnerTwo', + }), + ); + expect(result.line).toBe(5); + expect(result.isExactMatch).toBe(true); + }); + }); }); diff --git a/lana/src/salesforce/__tests__/ApexVisitor.test.ts b/lana/src/salesforce/__tests__/ApexVisitor.test.ts index a1b94b88..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' }) }, ], }), }), diff --git a/lana/src/salesforce/codesymbol/ApexSymbolParser.ts b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts new file mode 100644 index 00000000..0db153b5 --- /dev/null +++ b/lana/src/salesforce/codesymbol/ApexSymbolParser.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import type { SfdxProject } from './SfdxProject'; + +export type ApexSymbol = { + fullSymbol: string; + 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 || symbolParts.length < 2) { + 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(); + + const namespace = hasNamespace ? symbolParts[0] : null; + const outerClass = hasNamespace ? symbolParts[1] : symbolParts[0]; + const innerClass = getInnerClass(symbolParts, hasNamespace); + + return { + fullSymbol: symbol, + namespace, + outerClass, + innerClass, + 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); +} + +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; +} diff --git a/lana/src/salesforce/codesymbol/SfdxProject.ts b/lana/src/salesforce/codesymbol/SfdxProject.ts new file mode 100644 index 00000000..163fd5eb --- /dev/null +++ b/lana/src/salesforce/codesymbol/SfdxProject.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +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 new file mode 100644 index 00000000..0e318550 --- /dev/null +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; +import { type PackageDirectory, SfdxProject } from './SfdxProject'; + +export interface RawSfdxProject { + readonly name: string | null; + readonly namespace: string; + readonly packageDirectories: readonly PackageDirectory[]; +} + +export async function getProjects(workspaceFolder: WorkspaceFolder): Promise { + const projects: SfdxProject[] = []; + + const relativePattern = new RelativePattern(workspaceFolder, '**/sfdx-project.json'); + const sfdxProjectUris = await workspace.findFiles(relativePattern, '**/node_modules/**'); + + for (const uri of sfdxProjectUris) { + try { + const document = await workspace.openTextDocument(uri); + const content = document.getText(); + const rawProject = JSON.parse(content) as RawSfdxProject; + + 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) { + // eslint-disable-next-line no-console + console.warn(`Failed to parse sfdx-project.json at ${uri.fsPath}:`, error); + } + } + + return projects; +} diff --git a/lana/src/salesforce/codesymbol/SymbolFinder.ts b/lana/src/salesforce/codesymbol/SymbolFinder.ts index e1c3b3fb..f1a0571a 100644 --- a/lana/src/salesforce/codesymbol/SymbolFinder.ts +++ b/lana/src/salesforce/codesymbol/SymbolFinder.ts @@ -1,37 +1,49 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { type Workspace } from '@apexdevtools/apex-ls'; - -import { VSWorkspace } from '../../workspace/VSWorkspace.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) { - const apexWs = Workspaces.get(ws.path()); - const filePath = this.findInWorkspace(apexWs, symbol); - if (filePath) { - paths.push(filePath); - } - } - - return paths; + +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 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 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; + 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/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/salesforce/codesymbol/__tests__/ApexSymbolParser.test.ts b/lana/src/salesforce/codesymbol/__tests__/ApexSymbolParser.test.ts new file mode 100644 index 00000000..21d1c4af --- /dev/null +++ b/lana/src/salesforce/codesymbol/__tests__/ApexSymbolParser.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { parseSymbol, type ApexSymbol } from '../ApexSymbolParser'; +import { SfdxProject } from '../SfdxProject'; + +jest.mock('../SfdxProject'); + +function createProject(namespace: string): SfdxProject { + return new SfdxProject('test-project', namespace, [{ 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/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/codesymbol/__tests__/SfdxProjectReader.test.ts b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts new file mode 100644 index 00000000..d50bcde3 --- /dev/null +++ b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; +import { getProjects } from '../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), + }); + (Uri.joinPath as jest.Mock).mockReturnValue({ + path: '/workspace/force-app/sfdx-project.json', + }); + + const result = await getProjects(mockWorkspaceFolder); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'my-project', + namespace: 'myns', + packageDirectories: [{ path: '/workspace/force-app', default: true }], + }); + }); + + 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]) }); + (Uri.joinPath as jest.Mock).mockReturnValue({ path: '/workspace/sfdx-project.json' }); + + const result = await getProjects(mockWorkspaceFolder); + + 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 () => { + 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) }); + (Uri.joinPath as jest.Mock).mockReturnValue({ path: '/workspace/sfdx-project.json' }); + + const result = await getProjects(mockWorkspaceFolder); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject(validProject); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts b/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts new file mode 100644 index 00000000..5822c3bf --- /dev/null +++ b/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts @@ -0,0 +1,141 @@ +/* + * 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 '../ApexSymbolParser'; +import { findSymbol } from '../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 { + 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 { + 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 { + const manager = new VSWorkspaceManager(); + manager.workspaceFolders = workspaceFolders; + (manager.getWorkspaceForNamespacedProjects as jest.Mock).mockReturnValue(namespacedWorkspaces); + return manager; +} + +describe('SymbolFinder', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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 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 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 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 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 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 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/VSWorkspace.ts b/lana/src/workspace/VSWorkspace.ts index 38f79af7..e634acc1 100644 --- a/lana/src/workspace/VSWorkspace.ts +++ b/lana/src/workspace/VSWorkspace.ts @@ -1,10 +1,14 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { type WorkspaceFolder } from 'vscode'; +import { Uri, type WorkspaceFolder } from 'vscode'; +import type { ApexSymbol } from '../salesforce/codesymbol/ApexSymbolParser'; +import type { SfdxProject } from '../salesforce/codesymbol/SfdxProject'; +import { getProjects } from '../salesforce/codesymbol/SfdxProjectReader'; export class VSWorkspace { workspaceFolder: WorkspaceFolder; + sfdxProjectsByNamespace: Record = {}; constructor(workspaceFolder: WorkspaceFolder) { this.workspaceFolder = workspaceFolder; @@ -16,4 +20,40 @@ export class VSWorkspace { name(): string { return this.workspaceFolder.name; } + + 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 ?? ''; + + 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(); + } + + findClass(apexSymbol: ApexSymbol): Uri[] { + const projects = apexSymbol.namespace + ? this.getProjectsForNamespace(apexSymbol.namespace) + : this.getAllProjects(); + + return projects.flatMap((project) => project.findClass(apexSymbol.outerClass)); + } } diff --git a/lana/src/workspace/VSWorkspaceManager.ts b/lana/src/workspace/VSWorkspaceManager.ts new file mode 100644 index 00000000..f449adcb --- /dev/null +++ b/lana/src/workspace/VSWorkspaceManager.ts @@ -0,0 +1,42 @@ +/* + * 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'; +import { findSymbol } from '../salesforce/codesymbol/SymbolFinder'; +import { VSWorkspace } from './VSWorkspace'; + +export class VSWorkspaceManager { + workspaceFolders: VSWorkspace[] = []; + + constructor() { + if (workspace.workspaceFolders) { + this.workspaceFolders = workspace.workspaceFolders.map((folder) => { + return new VSWorkspace(folder); + }); + } + } + + async findSymbol(apexSymbol: ApexSymbol): Promise { + return await 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, + ); + } + + async initialiseWorkspaceProjectInfo(forceRefresh = false) { + await Promise.all( + this.workspaceFolders + .filter((folder) => forceRefresh || !folder.getAllProjects().length) + .map((folder) => folder.parseSfdxProjects()), + ); + } +} 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(); +} diff --git a/lana/src/workspace/__tests__/VSWorkspace.test.ts b/lana/src/workspace/__tests__/VSWorkspace.test.ts new file mode 100644 index 00000000..ee6254fc --- /dev/null +++ b/lana/src/workspace/__tests__/VSWorkspace.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +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 = { + 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 mockProjects = [ + new SfdxProject('project1', 'ns1', []), + new SfdxProject('project2', 'ns1', []), + new SfdxProject('project3', 'ns2', []), + new SfdxProject('project4', '', []), + ]; + + (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); + expect(mockProjects[0]!.buildClassIndex).toHaveBeenCalled(); + }); + }); + + describe('getProjectsForNamespace', () => { + it('should return empty array for unknown namespace', () => { + expect(vsWorkspace.getProjectsForNamespace('unknown')).toEqual([]); + }); + + it('should return projects matching the namespace', async () => { + 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(); + + expect(vsWorkspace.getProjectsForNamespace('ns1')).toEqual(ns1Projects); + }); + }); + + describe('getAllProjects', () => { + it('should return all projects across namespaces', async () => { + const mockProjects = [ + new SfdxProject('project1', 'ns1', []), + new SfdxProject('project2', 'ns2', []), + ]; + + (getProjects as jest.Mock).mockResolvedValue(mockProjects); + + await vsWorkspace.parseSfdxProjects(); + + expect(vsWorkspace.getAllProjects()).toEqual(mockProjects); + }); + }); + + describe('findClass', () => { + let mockProject1: SfdxProject; + let mockProject2: SfdxProject; + + beforeEach(async () => { + 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([mockProject1, mockProject2]); + await vsWorkspace.parseSfdxProjects(); + }); + + 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 = vsWorkspace.findClass({ + fullSymbol: 'ns1.MyClass.method()', + namespace: 'ns1', + outerClass: 'MyClass', + innerClass: null, + method: 'method', + parameters: '', + }); + + expect(result).toEqual([mockUri]); + expect(mockProject1.findClass).toHaveBeenCalledWith('MyClass'); + expect(mockProject2.findClass).not.toHaveBeenCalled(); + }); + + 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]); + + const result = vsWorkspace.findClass({ + fullSymbol: 'MyClass.method()', + namespace: null, + outerClass: 'MyClass', + innerClass: null, + method: 'method', + parameters: '', + }); + + 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 new file mode 100644 index 00000000..2079acae --- /dev/null +++ b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { workspace } from 'vscode'; +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(() => { + 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); + }); + + 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 = [new SfdxProject('p1', 'ns1', [])]; + const mockProjects2 = [new SfdxProject('p2', 'ns2', [])]; + + 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('initialiseWorkspaceProjectInfo', () => { + it('should call parseSfdxProjects on all workspaces', async () => { + const mockWorkspace1 = { + getAllProjects: jest.fn().mockReturnValue([]), + parseSfdxProjects: jest.fn().mockResolvedValue(undefined), + }; + const mockWorkspace2 = { + getAllProjects: jest.fn().mockReturnValue([]), + parseSfdxProjects: jest.fn().mockResolvedValue(undefined), + }; + + const manager = new VSWorkspaceManager(); + manager.workspaceFolders = [mockWorkspace1, mockWorkspace2] as unknown as VSWorkspace[]; + + await manager.initialiseWorkspaceProjectInfo(); + + 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: '', + }; + (findSymbol as jest.Mock).mockResolvedValueOnce(mockUri); + + const manager = new VSWorkspaceManager(); + + const result = await manager.findSymbol(mockSymbol); + + expect(findSymbol).toHaveBeenCalledWith(manager, mockSymbol); + expect(result).toEqual(mockUri); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 349faf02..eb683216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,9 +115,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 @@ -303,14 +300,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'} @@ -318,10 +307,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==} @@ -2881,11 +2866,6 @@ packages: '@webgpu/types@0.1.66': resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==} - '@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.* - '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -7901,18 +7881,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 @@ -7926,11 +7894,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) @@ -11440,8 +11403,6 @@ snapshots: '@webgpu/types@0.1.66': {} - '@xmldom/xmldom@0.7.9': {} - '@xmldom/xmldom@0.8.11': {} '@xtuc/ieee754@1.2.0': {}