diff --git a/client/src/commands.ts b/client/src/commands.ts index 5754466f..a53e29f8 100644 --- a/client/src/commands.ts +++ b/client/src/commands.ts @@ -19,7 +19,7 @@ import * as tasks from "./tasks"; import { DenoTestController, TestingFeature } from "./testing"; import type { DenoExtensionContext, TestCommandOptions } from "./types"; import { WelcomePanel } from "./welcome"; -import { assert, getDenoCommand } from "./util"; +import { assert, getDenoCommandName, getDenoCommandPath } from "./util"; import { registryState } from "./lsp_extensions"; import { createRegistryStateHandler } from "./notification_handlers"; import { DenoServerInfo } from "./server_info"; @@ -32,6 +32,7 @@ import type { Location, Position, } from "vscode-languageclient/node"; +import { getWorkspacesEnabledInfo } from "./enable"; // deno-lint-ignore no-explicit-any export type Callback = (...args: any[]) => unknown; @@ -125,7 +126,24 @@ export function startLanguageServer( } // Start a new language server - const command = await getDenoCommand(); + const command = await getDenoCommandPath(); + if (command == null) { + const message = + "Could not resolve Deno executable. Please ensure it is available " + + `on the PATH used by VS Code or set an explicit "deno.path" setting.`; + + // only show the message if the user has enabled deno or they have + // a deno configuration file and haven't explicitly disabled deno + const enabledInfo = await getWorkspacesEnabledInfo(); + const shouldShowMessage = enabledInfo + .some((e) => e.enabled || e.hasDenoConfig && e.enabled !== false); + if (shouldShowMessage) { + vscode.window.showErrorMessage(message); + } + extensionContext.outputChannel.appendLine(`Error: ${message}`); + return; + } + const serverOptions: ServerOptions = { run: { command, @@ -146,7 +164,10 @@ export function startLanguageServer( LANGUAGE_CLIENT_ID, LANGUAGE_CLIENT_NAME, serverOptions, - extensionContext.clientOptions, + { + outputChannel: extensionContext.outputChannel, + ...extensionContext.clientOptions, + }, ); const testingFeature = new TestingFeature(); client.registerFeature(testingFeature); @@ -274,9 +295,10 @@ export function test( assert(vscode.workspace.workspaceFolders); const target = vscode.workspace.workspaceFolders[0]; + const denoCommand = await getDenoCommandName(); const task = tasks.buildDenoTask( target, - await getDenoCommand(), + denoCommand, definition, `test "${name}"`, args, diff --git a/client/src/debug_config_provider.ts b/client/src/debug_config_provider.ts index 48f9365d..0586c487 100644 --- a/client/src/debug_config_provider.ts +++ b/client/src/debug_config_provider.ts @@ -1,7 +1,7 @@ // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. import type { Settings } from "./types"; -import { getDenoCommand } from "./util"; +import { getDenoCommandName } from "./util"; import * as vscode from "vscode"; export class DenoDebugConfigurationProvider @@ -43,7 +43,7 @@ export class DenoDebugConfigurationProvider program: "${workspaceFolder}/main.ts", cwd: "${workspaceFolder}", env: this.#getEnv(), - runtimeExecutable: await getDenoCommand(), + runtimeExecutable: await getDenoCommandName(), runtimeArgs: [ "run", ...this.#getAdditionalRuntimeArgs(), @@ -76,7 +76,7 @@ export class DenoDebugConfigurationProvider type: "pwa-node", program: "${file}", env: this.#getEnv(), - runtimeExecutable: await getDenoCommand(), + runtimeExecutable: await getDenoCommandName(), runtimeArgs: [ "run", ...this.#getAdditionalRuntimeArgs(), diff --git a/client/src/enable.ts b/client/src/enable.ts index d5f18298..00291684 100644 --- a/client/src/enable.ts +++ b/client/src/enable.ts @@ -4,43 +4,26 @@ import { ENABLE, ENABLE_PATHS, EXTENSION_NS } from "./constants"; import * as vscode from "vscode"; -async function exists(uri: vscode.Uri): Promise { - try { - await vscode.workspace.fs.stat(uri); - } catch { - return false; - } - return true; +export interface WorkspaceEnabledInfo { + folder: vscode.WorkspaceFolder; + enabled: boolean | undefined; + hasDenoConfig: boolean; } -/** - * @param folder the workspace folder to prompt about enablement - * @param only the workspace contains only a single folder or not - */ -async function promptEnableWorkspaceFolder( - folder: vscode.WorkspaceFolder, - only: boolean, -): Promise { - const prompt = only - ? `The workspace appears to be a Deno workspace. Do you wish to enable the Deno extension for this workspace?` - : `The workspace folder named "${folder.name}" appears to be a Deno workspace. Do you wish to enable the Deno extension for this workspace folder?`; - const selection = await vscode.window.showInformationMessage( - prompt, - "No", - "Enable", - ); - return selection === "Enable"; -} - -/** Iterate over the workspace folders, checking if the workspace isn't - * explicitly enabled or disabled, and if there is a Deno config file in the - * root of the workspace folder, offer to enable it. */ -async function checkEnabledWorkspaceFolders() { - if (!vscode.workspace.workspaceFolders) { - return; +export async function getWorkspacesEnabledInfo() { + const result: WorkspaceEnabledInfo[] = []; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + result.push({ + folder, + enabled: await getIsWorkspaceEnabled(folder), + hasDenoConfig: + await exists(vscode.Uri.joinPath(folder.uri, "./deno.json")) || + await exists(vscode.Uri.joinPath(folder.uri, "./deno.jsonc")), + }); } - const only = vscode.workspace.workspaceFolders.length === 1; - for (const workspaceFolder of vscode.workspace.workspaceFolders) { + return result; + + function getIsWorkspaceEnabled(workspaceFolder: vscode.WorkspaceFolder) { const config = vscode.workspace.getConfiguration( EXTENSION_NS, workspaceFolder, @@ -53,31 +36,17 @@ async function checkEnabledWorkspaceFolders() { typeof enable?.workspaceValue !== "undefined" || typeof enable?.globalValue !== "undefined" ) { - continue; + return config.get(ENABLE) ?? false; } + + // check for specific paths being enabled const enabledPaths = config.get(ENABLE_PATHS); - // if specific paths are already enabled, we should skip if (enabledPaths && enabledPaths.length) { - continue; - } - // if either a deno.json or deno.jsonc exists in the root of the workspace - // folder, we will prompt the user to enable Deno or not. - if ( - await exists(vscode.Uri.joinPath(workspaceFolder.uri, "./deno.json")) || - await exists(vscode.Uri.joinPath(workspaceFolder.uri, "./deno.jsonc")) - ) { - const enable = await promptEnableWorkspaceFolder(workspaceFolder, only); - // enable can be set on a workspace or workspace folder, when there is - // only one workspace folder, we still only want to update the config on - // the workspace, versus the folder - await config.update( - "enable", - enable, - only - ? vscode.ConfigurationTarget.Workspace - : vscode.ConfigurationTarget.WorkspaceFolder, - ); + return true; } + + // no setting set, so undefined + return undefined; } } @@ -107,3 +76,64 @@ export async function setupCheckConfig(): Promise { }, }; } + +async function exists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + } catch { + return false; + } + return true; +} + +/** + * @param folder the workspace folder to prompt about enablement + * @param only the workspace contains only a single folder or not + */ +async function promptEnableWorkspaceFolder( + folder: vscode.WorkspaceFolder, + only: boolean, +): Promise { + const prompt = only + ? `The workspace appears to be a Deno workspace. Do you wish to enable the Deno extension for this workspace?` + : `The workspace folder named "${folder.name}" appears to be a Deno workspace. Do you wish to enable the Deno extension for this workspace folder?`; + const selection = await vscode.window.showInformationMessage( + prompt, + "No", + "Enable", + ); + return selection === "Enable"; +} + +/** Iterate over the workspace folders, checking if the workspace isn't + * explicitly enabled or disabled, and if there is a Deno config file in the + * root of the workspace folder, offer to enable it. */ +async function checkEnabledWorkspaceFolders() { + const workspacesEnabledInfo = await getWorkspacesEnabledInfo(); + const only = workspacesEnabledInfo.length === 1; + for (const enabledInfo of workspacesEnabledInfo) { + // if the user has not configured enablement and either a deno.json or + // deno.jsonc exists in the root of the workspace folder, we will prompt + // the user to enable Deno or not. + if (enabledInfo.enabled == null && enabledInfo.hasDenoConfig) { + const enable = await promptEnableWorkspaceFolder( + enabledInfo.folder, + only, + ); + // enable can be set on a workspace or workspace folder, when there is + // only one workspace folder, we still only want to update the config on + // the workspace, versus the folder + const config = vscode.workspace.getConfiguration( + EXTENSION_NS, + enabledInfo.folder, + ); + await config.update( + "enable", + enable, + only + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.WorkspaceFolder, + ); + } + } +} diff --git a/client/src/extension.ts b/client/src/extension.ts index 712791a2..c4f734d1 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -1,7 +1,12 @@ // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. import * as commands from "./commands"; -import { ENABLE_PATHS, ENABLEMENT_FLAG, EXTENSION_NS } from "./constants"; +import { + ENABLE_PATHS, + ENABLEMENT_FLAG, + EXTENSION_NS, + LANGUAGE_CLIENT_NAME, +} from "./constants"; import { DenoTextDocumentContentProvider, SCHEME } from "./content_provider"; import { DenoDebugConfigurationProvider } from "./debug_config_provider"; import { setupCheckConfig } from "./enable"; @@ -172,6 +177,8 @@ const extensionContext = {} as DenoExtensionContext; export async function activate( context: vscode.ExtensionContext, ): Promise { + extensionContext.outputChannel = extensionContext.outputChannel ?? + vscode.window.createOutputChannel(LANGUAGE_CLIENT_NAME); extensionContext.clientOptions = { documentSelector: [ { scheme: "file", language: "javascript" }, diff --git a/client/src/tasks.ts b/client/src/tasks.ts index e040715d..91a93ff3 100644 --- a/client/src/tasks.ts +++ b/client/src/tasks.ts @@ -2,7 +2,7 @@ import { task as taskReq } from "./lsp_extensions"; import type { DenoExtensionContext } from "./types"; -import { getDenoCommand } from "./util"; +import { getDenoCommandName } from "./util"; import * as vscode from "vscode"; @@ -122,7 +122,7 @@ class DenoTaskProvider implements vscode.TaskProvider { const tasks: vscode.Task[] = []; - const process = await getDenoCommand(); + const process = await getDenoCommandName(); for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { for (const { command, group, problemMatchers } of defs) { const task = buildDenoTask( @@ -167,7 +167,7 @@ class DenoTaskProvider implements vscode.TaskProvider { if (isWorkspaceFolder(task.scope)) { return buildDenoTask( task.scope, - await getDenoCommand(), + await getDenoCommandName(), definition, task.name, args, @@ -178,7 +178,7 @@ class DenoTaskProvider implements vscode.TaskProvider { if (isWorkspaceFolder(task.scope)) { return buildDenoConfigTask( task.scope, - await getDenoCommand(), + await getDenoCommandName(), definition.name, definition.detail, ); diff --git a/client/src/types.d.ts b/client/src/types.d.ts index 12d49b05..4357c79f 100644 --- a/client/src/types.d.ts +++ b/client/src/types.d.ts @@ -40,6 +40,7 @@ export interface DenoExtensionContext { tsApi: TsApi; /** The current workspace settings. */ workspaceSettings: Settings; + outputChannel: vscode.OutputChannel; } export interface TestCommandOptions { diff --git a/client/src/util.ts b/client/src/util.ts index db487838..bf575129 100644 --- a/client/src/util.ts +++ b/client/src/util.ts @@ -15,29 +15,30 @@ export function assert(cond: unknown, msg = "Assertion failed."): asserts cond { } } -export async function getDenoCommand(): Promise { - let command = getWorkspaceConfigDenoExePath(); +/** Returns the absolute path to an existing deno command or + * the "deno" command name if not found. */ +export async function getDenoCommandName() { + return await getDenoCommandPath() ?? "deno"; +} + +/** Returns the absolute path to an existing deno command. */ +export async function getDenoCommandPath() { + const command = getWorkspaceConfigDenoExePath(); const workspaceFolders = vscode.workspace.workspaceFolders; - const defaultCommand = await getDefaultDenoCommand(); if (!command || !workspaceFolders) { - command = command ?? defaultCommand; + return command ?? await getDefaultDenoCommand(); } else if (!path.isAbsolute(command)) { // if sent a relative path, iterate over workspace folders to try and resolve. - const list = []; for (const workspace of workspaceFolders) { - const dir = path.resolve(workspace.uri.fsPath, command); - try { - const stat = await fs.promises.stat(dir); - if (stat.isFile()) { - list.push(dir); - } - } catch { - // we simply don't push onto the array if we encounter an error + const commandPath = path.resolve(workspace.uri.fsPath, command); + if (await fileExists(commandPath)) { + return commandPath; } } - command = list.shift() ?? defaultCommand; + return undefined; + } else { + return command; } - return command; } function getWorkspaceConfigDenoExePath() { @@ -51,58 +52,59 @@ function getWorkspaceConfigDenoExePath() { } } -function getDefaultDenoCommand() { - switch (os.platform()) { - case "win32": - return getDenoWindowsPath(); - default: - return Promise.resolve("deno"); - } - - async function getDenoWindowsPath() { - // Adapted from https://github.com/npm/node-which/blob/master/which.js - // Within vscode it will do `require("child_process").spawn("deno")`, - // which will prioritize "deno.exe" on the path instead of a possible - // higher precedence non-exe executable. This is a problem because, for - // example, version managers may have a `deno.bat` shim on the path. To - // ensure the resolution of the `deno` command matches what occurs on the - // command line, attempt to manually resolve the file path (issue #361). - const denoCmd = "deno"; - // deno-lint-ignore no-undef - const pathExtValue = process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM"; - // deno-lint-ignore no-undef - const pathValue = process.env.PATH ?? ""; - const pathExtItems = splitEnvValue(pathExtValue); - const pathFolderPaths = splitEnvValue(pathValue); +async function getDefaultDenoCommand() { + // Adapted from https://github.com/npm/node-which/blob/master/which.js + // Within vscode it will do `require("child_process").spawn("deno")`, + // which will prioritize "deno.exe" on the path instead of a possible + // higher precedence non-exe executable. This is a problem because, for + // example, version managers may have a `deno.bat` shim on the path. To + // ensure the resolution of the `deno` command matches what occurs on the + // command line, attempt to manually resolve the file path (issue #361). + const denoCmd = "deno"; + const pathValue = process.env.PATH ?? ""; + const pathFolderPaths = splitEnvValue(pathValue); + const pathExts = getPathExts(); + const cmdFileNames = pathExts == null + ? [denoCmd] + : pathExts.map((ext) => denoCmd + ext); - for (const pathFolderPath of pathFolderPaths) { - for (const pathExtItem of pathExtItems) { - const cmdFilePath = path.join(pathFolderPath, denoCmd + pathExtItem); - if (await fileExists(cmdFilePath)) { - return cmdFilePath; - } + for (const pathFolderPath of pathFolderPaths) { + for (const cmdFileName of cmdFileNames) { + const cmdFilePath = path.join(pathFolderPath, cmdFileName); + if (await fileExists(cmdFilePath)) { + return cmdFilePath; } } + } - // nothing found; return back command - return denoCmd; + // nothing found + return undefined; - function splitEnvValue(value: string) { - return value - .split(";") - .map((item) => item.trim()) - .filter((item) => item.length > 0); + function getPathExts() { + if (os.platform() === "win32") { + const pathExtValue = process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM"; + return splitEnvValue(pathExtValue); + } else { + return undefined; } } - function fileExists(executableFilePath: string): Promise { - return new Promise((resolve) => { - fs.stat(executableFilePath, (err, stat) => { - resolve(err == null && stat.isFile()); - }); - }).catch(() => { - // ignore all errors - return false; - }); + function splitEnvValue(value: string) { + const pathSplitChar = os.platform() === "win32" ? ";" : ":"; + return value + .split(pathSplitChar) + .map((item) => item.trim()) + .filter((item) => item.length > 0); } } + +function fileExists(executableFilePath: string): Promise { + return new Promise((resolve) => { + fs.stat(executableFilePath, (err, stat) => { + resolve(err == null && stat.isFile()); + }); + }).catch(() => { + // ignore all errors + return false; + }); +}