diff --git a/media/common/dep.svg b/media/common/dep.svg new file mode 100644 index 0000000..53e5be5 --- /dev/null +++ b/media/common/dep.svg @@ -0,0 +1,14 @@ + + + + Slice + Created with Sketch. + + + + + + + + + diff --git a/package.json b/package.json index 17fadfb..11f4b7a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,31 @@ }, "icon": "icon.png", "contributes": { + "viewsWelcome": [ + { + "view": "aderyn-panel-diagnostics-provider", + "contents": "Visit the [Welcome Page](command:aderyn.commands.showOnboardPanel) to get started", + "when": "!aderyn-panel-diagnostics-provider.hasItems" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "aderyn-panel-diagnostics", + "title": "Aderyn Diagnostics", + "icon": "media/common/dep.svg" + } + ] + }, "views": { + "aderyn-panel-diagnostics": [ + { + "id": "aderyn-panel-diagnostics-provider", + "name": "Aderyn Diagnostics", + "icon": "media/common/dep.svg", + "contextualTitle": "Aderyn Diagnostics" + } + ], "explorer": [ { "type": "webview", diff --git a/src/extension.ts b/src/extension.ts index 449192a..d5fbe2c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,12 @@ import * as vscode from 'vscode'; +import { + hasRecognizedProjectStructureAtWorkspaceRoot, + isKeyUsed, + isWindowsNotWSL, + Keys, + Logger, +} from './utils'; + import { createOrInitLspClient, startServing, @@ -6,10 +14,11 @@ import { createOrInitOnboardProvider, createAderynStatusItem, } from './state'; + import { registerEditorCommands } from './commands'; import { registerWebviewPanels } from './webview-providers'; -import { isKeyUsed, isWindowsNotWSL, Keys, Logger } from './utils'; import { registerStatusBarItems } from './state/statusbar/index'; +import { registerDataProviders } from './panel-providers'; export function activate(context: vscode.ExtensionContext) { if (isWindowsNotWSL()) { @@ -25,13 +34,14 @@ export function activate(context: vscode.ExtensionContext) { .then(() => registerWebviewPanels(context)) .then(() => registerEditorCommands(context)) .then(() => registerStatusBarItems(context)) + .then(() => registerDataProviders(context)) .then(autoStartLspClientIfRequested); } async function autoStartLspClientIfRequested() { const config = vscode.workspace.getConfiguration('aderyn.config'); const userPrefersAutoStart = config.get('autoStart'); - if (userPrefersAutoStart) { + if (userPrefersAutoStart && hasRecognizedProjectStructureAtWorkspaceRoot()) { try { startServing(); } catch (_ex) { diff --git a/src/panel-providers/diagnostics-panel.ts b/src/panel-providers/diagnostics-panel.ts new file mode 100644 index 0000000..c24eaf7 --- /dev/null +++ b/src/panel-providers/diagnostics-panel.ts @@ -0,0 +1,153 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { + ensureWorkspacePreconditionsMetAndReturnProjectURI, + findProjectRoot, +} from '../utils/index'; +import { + createAderynReportAndDeserialize, + isAderynAvailableOnPath, +} from '../utils/install/aderyn'; +import { Report, IssueInstance, Issue } from '../utils/install/issues'; +import { Logger } from '../utils/logger'; + +class AderynDiagnosticsProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event; + + projectRootUri: string | null = null; + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: DiagnosticItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: DiagnosticItem): Promise { + if (!element) { + return this.prepareResults().then(this.getTopLevelItems); + } + if (element.itemKind == ItemKind.Category) { + return this.getIssueItems(element as CategoryItem); + } + if (element.itemKind == ItemKind.Issue) { + return this.getInstances(element as IssueItem); + } + return Promise.resolve([]); + } + + async prepareResults(): Promise { + const logger = new Logger(); + const aderynIsOnPath = await isAderynAvailableOnPath(logger); + if (aderynIsOnPath) { + const workspaceRoot = + ensureWorkspacePreconditionsMetAndReturnProjectURI(false); + if (!workspaceRoot) { + return Promise.reject('workspace pre-conditions unmet'); + } + this.projectRootUri = findProjectRoot(workspaceRoot); + return await createAderynReportAndDeserialize(this.projectRootUri).catch( + (err) => { + logger.err(err); + vscode.window.showErrorMessage('Error fetching results from aderyn'); + return null; + }, + ); + } + return null; + } + + getTopLevelItems(report: Report | null): DiagnosticItem[] { + if (!report) { + return []; + } + const highIssues = report.highIssues.issues; + const lowIssues = report.lowIssues.issues; + return [new CategoryItem('High', highIssues), new CategoryItem('Low', lowIssues)]; + } + + getIssueItems(category: CategoryItem): DiagnosticItem[] { + return category.issues.map((issue) => new IssueItem(issue)); + } + + getInstances(issueItem: IssueItem): DiagnosticItem[] { + return issueItem.issue.instances.map( + (instance) => new InstanceItem(instance, this.projectRootUri ?? '.'), + ); + } +} + +const enum ItemKind { + Category, + Issue, + Instance, +} + +export class DiagnosticItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public readonly itemKind: ItemKind, + ) { + super(label, collapsibleState); + this.tooltip = `${this.label}`; + } +} + +class CategoryItem extends DiagnosticItem { + constructor( + public readonly label: string, + public readonly issues: Issue[], + ) { + super( + label, + issues.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.Expanded, + ItemKind.Category, + ); + this.tooltip = `${this.label} issues`; + } +} + +class IssueItem extends DiagnosticItem { + constructor(public readonly issue: Issue) { + super(issue.title, vscode.TreeItemCollapsibleState.Collapsed, ItemKind.Issue); + this.tooltip = issue.description; + } +} + +class InstanceItem extends DiagnosticItem { + constructor( + public readonly instance: IssueInstance, + projectRootUri: string, + ) { + super( + `${instance.contractPath} Line: ${instance.lineNo} ${instance.srcChar}`, + vscode.TreeItemCollapsibleState.None, + ItemKind.Instance, + ); + this.tooltip = instance.contractPath; + this.command = { + title: 'Go to file', + command: 'vscode.open', + arguments: [ + vscode.Uri.file(path.join(projectRootUri, instance.contractPath)), + { + selection: new vscode.Range( + instance.lineNo - 1, + 0, + instance.lineNo - 1, + 1000, + ), + }, + ], + }; + } +} + +export { AderynDiagnosticsProvider }; diff --git a/src/panel-providers/index.ts b/src/panel-providers/index.ts new file mode 100644 index 0000000..937aeb6 --- /dev/null +++ b/src/panel-providers/index.ts @@ -0,0 +1,4 @@ +import { AderynDiagnosticsProvider } from './diagnostics-panel'; +import { registerDataProviders } from './registrations'; + +export { AderynDiagnosticsProvider, registerDataProviders }; diff --git a/src/panel-providers/registrations.ts b/src/panel-providers/registrations.ts new file mode 100644 index 0000000..cf7db41 --- /dev/null +++ b/src/panel-providers/registrations.ts @@ -0,0 +1,19 @@ +import * as vscode from 'vscode'; + +import { PanelProviders } from './variants'; +import { AderynDiagnosticsProvider as D } from './diagnostics-panel'; + +function registerDataProviders(context: vscode.ExtensionContext) { + const diagnosticsDataProvider = new D(); + const diagnosticsTreeView = vscode.window.createTreeView(PanelProviders.Diagnostics, { + treeDataProvider: diagnosticsDataProvider, + }); + diagnosticsTreeView.onDidChangeVisibility((e) => { + if (e.visible) { + diagnosticsDataProvider.refresh(); + } + }); + context.subscriptions.push(diagnosticsTreeView); +} + +export { registerDataProviders }; diff --git a/src/panel-providers/variants.ts b/src/panel-providers/variants.ts new file mode 100644 index 0000000..50edbfa --- /dev/null +++ b/src/panel-providers/variants.ts @@ -0,0 +1,5 @@ +const enum PanelProviders { + Diagnostics = 'aderyn-panel-diagnostics-provider', +} + +export { PanelProviders }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 932c4d9..2443011 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,6 +4,7 @@ import { executeCommand, isWindowsNotWSL, ensureWorkspacePreconditionsMetAndReturnProjectURI, + hasRecognizedProjectStructureAtWorkspaceRoot, } from './runtime'; import { ensureAderynIsInstalled } from './install'; import { isKeyUsed, Keys } from './keys'; @@ -12,6 +13,7 @@ import { readAderynConfigTemplate } from './metadata'; export { findProjectRoot, + hasRecognizedProjectStructureAtWorkspaceRoot, ensureWorkspacePreconditionsMetAndReturnProjectURI, readAderynConfigTemplate, isWindowsNotWSL, diff --git a/src/utils/install/aderyn.ts b/src/utils/install/aderyn.ts index 5ebd0bb..4510394 100644 --- a/src/utils/install/aderyn.ts +++ b/src/utils/install/aderyn.ts @@ -1,5 +1,10 @@ -import { executeCommand } from '../runtime'; +import { + ensureWorkspacePreconditionsMetAndReturnProjectURI, + executeCommand, + findProjectRoot, +} from '../runtime'; import { Logger } from '../logger'; +import { parseAderynReportFromJsonString, Report } from './issues'; /** * Checks if the command "aderyn" is available on path in the shell @@ -19,4 +24,17 @@ async function isAderynAvailableOnPath(logger: Logger): Promise { }); } -export { isAderynAvailableOnPath }; +async function createAderynReportAndDeserialize(projectRootUri: string): Promise { + const cmd = `aderyn ${projectRootUri} -o report.json --stdout --skip-cloc`; + return executeCommand(cmd) + .then((text) => { + const match = text.match(/STDOUT START([\s\S]*?)STDOUT END/); + if (!match) { + throw new Error('corrupted json'); + } + return match[1].trim(); + }) + .then((reportJson) => parseAderynReportFromJsonString(reportJson)); +} + +export { isAderynAvailableOnPath, createAderynReportAndDeserialize }; diff --git a/src/utils/install/index.ts b/src/utils/install/index.ts index 332e033..f8506d8 100644 --- a/src/utils/install/index.ts +++ b/src/utils/install/index.ts @@ -1,7 +1,7 @@ import { Logger } from '../logger'; import { readPackageJson } from '../metadata'; import { hasReliableInternet } from '../runtime'; -import { isAderynAvailableOnPath } from './aderyn'; +import { isAderynAvailableOnPath, createAderynReportAndDeserialize } from './aderyn'; import { whichAderyn, installAderynWithAppropriateCmd, @@ -155,4 +155,4 @@ async function ensureAderynIsInstalled(): Promise { } } -export { ensureAderynIsInstalled }; +export { ensureAderynIsInstalled, createAderynReportAndDeserialize }; diff --git a/src/utils/install/issues.ts b/src/utils/install/issues.ts new file mode 100644 index 0000000..17bcb3e --- /dev/null +++ b/src/utils/install/issues.ts @@ -0,0 +1,98 @@ +// Run aderyn and deserialize the issues for upstream consumption +// Ex - Used by Diagnostics Panel + +interface FilesSummary { + totalSourceUnits: number; + totalSloc: number; +} + +interface FileDetail { + filePath: string; + nSloc: number; +} + +interface FilesDetails { + filesDetails: FileDetail[]; +} + +interface IssueInstance { + contractPath: string; + lineNo: number; + src: string; + srcChar: string; +} + +interface Issue { + title: string; + description: string; + detectorName: string; + instances: IssueInstance[]; +} + +interface Issues { + issues: Issue[]; +} + +interface IssueCount { + high: number; + low: number; +} + +interface Report { + filesSummary: FilesSummary; + filesDetails: FilesDetails; + issueCount: IssueCount; + highIssues: Issues; + lowIssues: Issues; + detectorsUsed: string[]; +} + +function parseAderynReportFromJsonString(json: string): Report { + const obj = JSON.parse(json); + + return { + filesSummary: { + totalSourceUnits: obj.files_summary.total_source_units, + totalSloc: obj.files_summary.total_sloc, + }, + filesDetails: { + filesDetails: obj.files_details.files_details.map((file: any) => ({ + filePath: file.file_path, + nSloc: file.n_sloc, + })), + }, + issueCount: { + high: obj.issue_count.high, + low: obj.issue_count.low, + }, + highIssues: { + issues: obj.high_issues.issues.map((issue: any) => ({ + title: issue.title, + description: issue.description, + detectorName: issue.detector_name, + instances: issue.instances.map((instance: any) => ({ + contractPath: instance.contract_path, + lineNo: instance.line_no, + src: instance.src, + srcChar: instance.src_char, + })), + })), + }, + lowIssues: { + issues: obj.low_issues.issues.map((issue: any) => ({ + title: issue.title, + description: issue.description, + detectorName: issue.detector_name, + instances: issue.instances.map((instance: any) => ({ + contractPath: instance.contract_path, + lineNo: instance.line_no, + src: instance.src, + srcChar: instance.src_char, + })), + })), + }, + detectorsUsed: obj.detectors_used, + }; +} + +export { parseAderynReportFromJsonString, Report, Issue, IssueInstance }; diff --git a/src/utils/runtime/index.ts b/src/utils/runtime/index.ts index 5c1e121..61d67c8 100644 --- a/src/utils/runtime/index.ts +++ b/src/utils/runtime/index.ts @@ -4,7 +4,11 @@ import * as project from './project'; import { ExecuteCommandErrorType, ExecuteCommandError, SystemInfo } from './system'; const { getSystemInfo, executeCommand, hasReliableInternet, isWindowsNotWSL } = system; -const { findProjectRoot, ensureWorkspacePreconditionsMetAndReturnProjectURI } = project; +const { + findProjectRoot, + hasRecognizedProjectStructureAtWorkspaceRoot, + ensureWorkspacePreconditionsMetAndReturnProjectURI, +} = project; export { findProjectRoot, @@ -12,6 +16,7 @@ export { executeCommand, hasReliableInternet, isWindowsNotWSL, + hasRecognizedProjectStructureAtWorkspaceRoot, ensureWorkspacePreconditionsMetAndReturnProjectURI, SystemInfo, ExecuteCommandError, diff --git a/src/utils/runtime/project.ts b/src/utils/runtime/project.ts index ba2b64d..a8591a0 100644 --- a/src/utils/runtime/project.ts +++ b/src/utils/runtime/project.ts @@ -18,7 +18,20 @@ function findProjectRoot(projectRootUri: string): string { } currentDir = path.dirname(currentDir); } - return projectRootUri; + return currentDir; +} + +function hasRecognizedProjectStructureAtWorkspaceRoot(): boolean { + const workspaceRoot = ensureWorkspacePreconditionsMetAndReturnProjectURI(false); + if (!workspaceRoot) return false; + const root = findProjectRoot(workspaceRoot); + return ( + root != null && + (fs.existsSync(path.join(root, 'aderyn.toml')) || + fs.existsSync(path.join(root, 'foundry.toml')) || + fs.existsSync(path.join(root, 'hardhat.config.ts')) || + fs.existsSync(path.join(root, 'hardhat.config.js'))) + ); } function ensureWorkspacePreconditionsMetAndReturnProjectURI( @@ -40,4 +53,8 @@ function ensureWorkspacePreconditionsMetAndReturnProjectURI( return projectRootUri.toString().substring('file://'.length); } -export { findProjectRoot, ensureWorkspacePreconditionsMetAndReturnProjectURI }; +export { + findProjectRoot, + hasRecognizedProjectStructureAtWorkspaceRoot, + ensureWorkspacePreconditionsMetAndReturnProjectURI, +}; diff --git a/src/webview-providers/onboard-panel/HowToUse.svelte b/src/webview-providers/onboard-panel/HowToUse.svelte index ab99809..f477fd1 100644 --- a/src/webview-providers/onboard-panel/HowToUse.svelte +++ b/src/webview-providers/onboard-panel/HowToUse.svelte @@ -6,8 +6,8 @@
  • Aderyn will start automatically on commonly recognized project structures - i.e when a foundry.toml or hardhat.config.ts is found in the - root of the workspace + i.e when a foundry.toml or hardhat.config.ts or + aderyn.toml is found in the root of the workspace
  • If the project's framework config file is nested in some directory or is