Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
85d1a0d
fix: account for inner class types in method params for go-to functio…
jjocoulter Sep 17, 2025
64d2c19
fix: ensure methods can be matched using qualified or unqualified names
jjocoulter Sep 17, 2025
7ae523d
test: add tests for fuzzy parameter matching
jjocoulter Sep 17, 2025
40fcc7f
test: fix apex visitor test
jjocoulter Sep 17, 2025
4129b81
fix: stop apex-ls throwing errors from sfdx project issues
jjocoulter Oct 10, 2025
fd9eedc
feat: implement workspace manager and class finder to replace apex-ls
jjocoulter Nov 20, 2025
b547359
fix: ensure projects are loaded before attempting to parse symbols
jjocoulter Nov 24, 2025
66bdf17
test: update existing tests
jjocoulter Nov 24, 2025
d79d0be
test: add vscode api mocks
jjocoulter Nov 24, 2025
a57cd3b
format: refactor type construction to be more readable
jjocoulter Nov 24, 2025
43afebc
test: add tests for new functionality
jjocoulter Nov 24, 2025
43233cd
feat: implement quick pick when multiple files matched for a symbol
jjocoulter Nov 24, 2025
b76e969
test: add symbol finder tests
jjocoulter Nov 24, 2025
8cceb2f
Merge branch 'main' of https://github.com/certinia/debug-log-analyzer…
jjocoulter Nov 24, 2025
555d5cf
fix: store full path to sfdx project instead of relative
jjocoulter Nov 24, 2025
0cf95e6
feat: cache sfdx projects and class paths
jjocoulter Nov 25, 2025
34c9c01
test: add mocks for new classes
jjocoulter Nov 25, 2025
9b3e0ae
test: update tests
jjocoulter Nov 25, 2025
994d71f
test: add tests for SfdxProject
jjocoulter Nov 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion lana/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 10 additions & 15 deletions lana/src/Context.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<Uri | null> {
const path = await this.workspaceManager.findSymbol(apexSymbol);

if (!path) {
this.display.showErrorMessage(`Type '${apexSymbol.fullSymbol}' was not found in workspace`);
}

return path;
}
}
32 changes: 32 additions & 0 deletions lana/src/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
@@ -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(),
})),
};
43 changes: 9 additions & 34 deletions lana/src/display/OpenFileInPackage.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,44 @@
/*
* 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<void> {
if (!symbolName?.trim()) {
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;
Expand All @@ -77,6 +52,6 @@ export class OpenFileInPackage {
selection: new Selection(pos, pos),
};

context.display.showFile(path, options);
commands.executeCommand('vscode.open', uri, options);
}
}
10 changes: 6 additions & 4 deletions lana/src/display/QuickPickWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { Item, Options, QuickPick } from './QuickPick.js';

export class QuickPickWorkspace {
static async pickOrReturn(context: Context): Promise<string> {
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:'),
);

Expand All @@ -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;
Expand Down
79 changes: 45 additions & 34 deletions lana/src/salesforce/ApexParser/ApexSymbolLocator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion lana/src/salesforce/ApexParser/ApexVisitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class ApexVisitor implements ApexParserVisitor<ApexNode> {

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) {
Expand Down
Loading
Loading