diff --git a/package-lock.json b/package-lock.json index f9617b0..c208998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "path-browserify": "^1.0.1", "tar-fs": "^3.0.6", "vue": "^3.4.27", + "which": "^4.0.0", "yosys2digitaljs": "^0.8.0" }, "devDependencies": { @@ -28,6 +29,7 @@ "@types/vscode": "1.73.0", "@types/vscode-webview": "^1.57.4", "@types/webpack-env": "^1.18.4", + "@types/which": "^3.0.4", "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "@vscode/test-web": "^0.0.54", @@ -809,6 +811,12 @@ "integrity": "sha512-wz7kjjRRj8/Lty4B+Kr0LN6Ypc/3SymeCCGSbaXp2leH0ZVg/PriNiOwNj4bD4uphI7A8NXS4b6Gl373sfO5mA==", "dev": true }, + "node_modules/@types/which": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", + "integrity": "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.13.0.tgz", @@ -1960,6 +1968,27 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-loader": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", @@ -3366,10 +3395,12 @@ "dev": true }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "engines": { + "node": ">=16" + } }, "node_modules/isobject": { "version": "3.0.1", @@ -6100,18 +6131,17 @@ } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "dependencies": { - "isexe": "^2.0.0" + "isexe": "^3.1.1" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" + "node": "^16.13.0 || >=18.0.0" } }, "node_modules/which-typed-array": { diff --git a/package.json b/package.json index 86e4f65..a5812a0 100644 --- a/package.json +++ b/package.json @@ -34,19 +34,22 @@ "properties": { "edacation.toolProvider": { "type": "string", - "markdownDescription": "Specifies the tool provider used to run Yosys and Nextpnr. See the [docs](https://github.com/EDAcation/vscode-edacation/blob/main/docs/tool-provider.md) for more information.", - "default": "native-managed", + "markdownDescription": "Specifies the tool provider used to run Yosys and Nextpnr. [Learn more](https://github.com/EDAcation/vscode-edacation/blob/main/docs/tool-provider.md)", + "default": "auto", "enum": [ + "auto", "native-managed", "native-host", "web" ], "enumItemLabels": [ + "Automatic", "Native (Managed)", "Native (Host)", "Web" ], "enumDescriptions": [ + "Let EDAcation choose which provider to use. Native providers are used where possible, and pre-installed tools are preferred.", "Let EDAcation install and manage native Yosys & Nextpnr tools on your system. Only available on VSCode for Desktop on certain platforms.", "Use native Yosys & Nextpnr tools that are already present on the system. Requires said tools to be available in PATH. Only available on VSCode for Desktop.", "Use WebAssembly versions of Yosys & Nextpnr. This option requires an active internet connection. Slower, but available on all platforms and environments." @@ -364,6 +367,7 @@ "@types/vscode": "1.73.0", "@types/vscode-webview": "^1.57.4", "@types/webpack-env": "^1.18.4", + "@types/which": "^3.0.4", "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "@vscode/test-web": "^0.0.54", @@ -395,6 +399,7 @@ "path-browserify": "^1.0.1", "tar-fs": "^3.0.6", "vue": "^3.4.27", + "which": "^4.0.0", "yosys2digitaljs": "^0.8.0" } } diff --git a/src/common/node-modules.ts b/src/common/node-modules.ts index 2d959b5..9680cd8 100644 --- a/src/common/node-modules.ts +++ b/src/common/node-modules.ts @@ -28,6 +28,7 @@ export type ModuleFS = typeof import('fs'); export type ModuleOS = typeof import('os'); export type ModuleProcess = typeof import('process'); export type ModuleStream = typeof import('stream'); +export type ModuleWhich = typeof import('which'); export type ModuleWorkerThreads = typeof import('worker_threads'); export type ModuleZLib = typeof import('zlib'); @@ -36,5 +37,6 @@ export const fs = () => requireModule('fs') as ModuleFS; export const os = () => requireModule('os') as ModuleOS; export const process = () => requireModule('process') as ModuleProcess; export const stream = () => requireModule('stream') as ModuleStream; +export const which = () => requireModule('which') as ModuleWhich; export const workerThreads = () => requireModule('worker_threads') as ModuleWorkerThreads; export const zlib = () => requireModule('zlib') as ModuleZLib; diff --git a/src/extension/tasks/messaging.ts b/src/extension/tasks/messaging.ts index 259d0bc..9c77226 100644 --- a/src/extension/tasks/messaging.ts +++ b/src/extension/tasks/messaging.ts @@ -42,7 +42,7 @@ export abstract class TerminalMessageEmitter { this.messageEvent.event(callback); } - private fire(message: TerminalMessage) { + protected fire(message: TerminalMessage) { this.messageEvent.fire(message); } diff --git a/src/extension/tasks/task.ts b/src/extension/tasks/task.ts index f17bb2e..e8e31ee 100644 --- a/src/extension/tasks/task.ts +++ b/src/extension/tasks/task.ts @@ -4,8 +4,8 @@ import type * as vscode from 'vscode'; import {type Project} from '../projects/index.js'; -import {AnsiModifier, type TaskOutputFile, type TerminalMessage, TerminalMessageEmitter} from './messaging.js'; -import {ToolProvider} from './toolprovider.js'; +import {AnsiModifier, type TaskOutputFile, TerminalMessageEmitter} from './messaging.js'; +import {type ToolProvider} from './toolprovider.js'; export interface TaskDefinition extends vscode.TaskDefinition { project: string; @@ -48,7 +48,10 @@ export abstract class TerminalTask extends this.toolProvider = toolProvider; // proxy all provider messages to terminal - this.toolProvider.onMessage(this.onProviderMessage.bind(this)); + this.toolProvider.onMessage((msg) => { + if (this.isDisabled) return; + return this.fire(msg); + }); this.isDisabled = false; } @@ -69,25 +72,6 @@ export abstract class TerminalTask extends abstract handleEnd(project: Project, outputFiles: TaskOutputFile[]): Promise; - private onProviderMessage(message: TerminalMessage) { - if (this.isDisabled) return; - - switch (message.type) { - case 'println': { - this.println(message.line, message.stream); - break; - } - case 'done': { - this.done(message.outputFiles); - break; - } - case 'error': { - this.error(message.error); - break; - } - } - } - async execute(project: Project, targetId: string) { const workerOptions = this.getWorkerOptions(project, targetId); @@ -115,18 +99,21 @@ export abstract class TerminalTask extends } this.println(); - // Print the tool provider and command to execute - this.println(`Tool command (${this.toolProvider.getName()}):`, undefined, AnsiModifier.BOLD); - this.println(` ${command} ${args.join(' ')}`); - this.println(); - - await this.toolProvider.run({ + this.toolProvider.setRunContext({ project, command, args, inputFiles, outputFiles }); + + // Print the tool provider and command to execute + const toolName = await this.toolProvider.getName(); + this.println(`Tool command (${toolName}):`, undefined, AnsiModifier.BOLD); + this.println(` ${command} ${args.join(' ')}`); + this.println(); + + await this.toolProvider.execute(); } cleanup() { diff --git a/src/extension/tasks/toolprovider.ts b/src/extension/tasks/toolprovider.ts index cd0bf9d..6ff3345 100644 --- a/src/extension/tasks/toolprovider.ts +++ b/src/extension/tasks/toolprovider.ts @@ -18,10 +18,11 @@ interface Context { outputFiles: TaskIOFile[]; } -type ToolConfigOption = 'native-managed' | 'native-host' | 'web'; +type ToolConfigOption = 'auto' | 'native-managed' | 'native-host' | 'web'; export abstract class ToolProvider extends TerminalMessageEmitter { protected readonly extensionContext: vscode.ExtensionContext; + protected ctx: Context | null = null; constructor(extensionContext: vscode.ExtensionContext) { super(); @@ -29,13 +30,22 @@ export abstract class ToolProvider extends TerminalMessageEmitter { this.extensionContext = extensionContext; } - abstract getName(): string; + abstract getName(): Promise; - abstract run(ctx: Context): Promise; + protected abstract run(ctx: Context): Promise; + + setRunContext(ctx: Context) { + this.ctx = ctx; + } + + async execute(): Promise { + if (!this.ctx) throw new Error('No run context available!'); + return await this.run(this.ctx); + } } export class WebAssemblyToolProvider extends ToolProvider { - getName() { + async getName(): Promise { return 'WebAssembly'; } @@ -224,7 +234,7 @@ abstract class NativeToolProvider extends ToolProvider { } export class ManagedToolProvider extends NativeToolProvider { - getName(): string { + async getName(): Promise { return 'Native - Managed'; } @@ -244,20 +254,62 @@ export class ManagedToolProvider extends NativeToolProvider { } export class HostToolProvider extends NativeToolProvider { - getName(): string { + async getName(): Promise { return 'Native - Host'; } async getExecutionOptions(command: string): Promise { - // Host tools should be installed to PATH. Just return the command here - the OS should resolve it. - return {entrypoint: command}; + const entrypoint = await node.which()(command, {nothrow: true}); + if (!entrypoint) return null; + + return {entrypoint}; + } +} + +export class AutomaticToolProvider extends ToolProvider { + private toolProvider: ToolProvider | null = null; + + async getName(): Promise { + if (!this.ctx) return 'Automatic'; + + const provider = await this.getToolProvider(this.ctx.command); + return `Automatic [${await provider.getName()}]`; + } + + private async getToolProvider(command: string): Promise { + if (this.toolProvider) return this.toolProvider; + + // Always use Web provider in non-node environments + const webProvider = new WebAssemblyToolProvider(this.extensionContext); + if (!node.isAvailable()) return webProvider; + + // Use host provider if tool is installed + const hostProvider = new HostToolProvider(this.extensionContext); + if (await hostProvider.getExecutionOptions(command)) return hostProvider; + + // Use managed provider, unless installation somehow fails + const managedProvider = new ManagedToolProvider(this.extensionContext); + if (await managedProvider.getExecutionOptions(command)) return managedProvider; + + // Fall back to web provider + return webProvider; + } + + async run(ctx: Context): Promise { + const provider = await this.getToolProvider(ctx.command); + provider.onMessage(this.fire.bind(this)); + + provider.setRunContext(ctx); + return await provider.execute(); } } export const getConfiguredProvider = (context: vscode.ExtensionContext): ToolProvider => { const provider = vscode.workspace.getConfiguration('edacation').get('toolProvider') as ToolConfigOption; - if (provider === 'native-managed') { + if (provider === 'auto') { + return new AutomaticToolProvider(context); + } else if (provider === 'native-managed') { return new ManagedToolProvider(context); } else if (provider === 'native-host') { return new HostToolProvider(context);