diff --git a/client/src/commands.ts b/client/src/commands.ts index 7d7be993..504f6742 100644 --- a/client/src/commands.ts +++ b/client/src/commands.ts @@ -33,11 +33,12 @@ import * as dotenv from "dotenv"; import * as vscode from "vscode"; import { LanguageClient, ServerOptions } from "vscode-languageclient/node"; import type { Location, Position } from "vscode-languageclient/node"; -import { getWorkspacesEnabledInfo } from "./enable"; +import { getWorkspacesEnabledInfo, isPathEnabled } from "./enable"; import { denoUpgradePromptAndExecute } from "./upgrade"; -import { join } from "path"; -import { readFileSync } from "fs"; +import * as fs from "fs"; +import * as path from "path"; import * as process from "process"; +import * as jsoncParser from "jsonc-parser/lib/esm/main.js"; import { semver } from "./semver"; // deno-lint-ignore no-explicit-any @@ -66,6 +67,15 @@ export function cacheActiveDocument( }; } +export function clearHiddenPromptStorage( + context: vscode.ExtensionContext, + _extensionContext: DenoExtensionContext, +): Callback { + return () => { + context.globalState.update("deno.tsConfigPathsWithPromptHidden", []); + }; +} + export function info( context: vscode.ExtensionContext, extensionContext: DenoExtensionContext, @@ -129,9 +139,9 @@ export function startLanguageServer( const denoEnvFile = config.get("envFile"); if (denoEnvFile) { if (workspaceFolder) { - const denoEnvPath = join(workspaceFolder.uri.fsPath, denoEnvFile); + const denoEnvPath = path.join(workspaceFolder.uri.fsPath, denoEnvFile); try { - const content = readFileSync(denoEnvPath, { encoding: "utf8" }); + const content = fs.readFileSync(denoEnvPath, { encoding: "utf8" }); const parsed = dotenv.parse(content); Object.assign(env, parsed); } catch (error) { @@ -233,8 +243,9 @@ export function startLanguageServer( extensionContext.clientSubscriptions.push( extensionContext.client.onNotification( "deno/didChangeDenoConfiguration", - ({ changes }: DidChangeDenoConfigurationParams) => { + async ({ changes }: DidChangeDenoConfigurationParams) => { let changedScopes = false; + const addedDenoJsonUris = []; for (const change of changes) { if (change.configurationType != "denoJson") { continue; @@ -243,6 +254,7 @@ export function startLanguageServer( const scopePath = vscode.Uri.parse(change.scopeUri).fsPath; scopesWithDenoJson.add(scopePath); changedScopes = true; + addedDenoJsonUris.push(vscode.Uri.parse(change.fileUri)); } else if (change.type == "removed") { const scopePath = vscode.Uri.parse(change.scopeUri).fsPath; scopesWithDenoJson.delete(scopePath); @@ -253,6 +265,13 @@ export function startLanguageServer( extensionContext.tsApi?.refresh(); } extensionContext.tasksSidebar.refresh(); + for (const addedDenoJsonUri of addedDenoJsonUris) { + await maybeShowTsConfigPrompt( + context, + extensionContext, + addedDenoJsonUri, + ); + } }, ), ); @@ -330,6 +349,151 @@ function showWelcomePageIfFirstUse( } } +function isObject(value: unknown) { + return value && typeof value == "object" && !Array.isArray(value); +} + +/** + * For a discovered deno.json file, see if there's an adjacent tsconfig.json. + * Offer options to either copy over the compiler options from it, or disable + * the Deno LSP if it contains plugins. + */ +async function maybeShowTsConfigPrompt( + context: vscode.ExtensionContext, + extensionContext: DenoExtensionContext, + denoJsonUri: vscode.Uri, +) { + const denoJsonPath = denoJsonUri.fsPath; + if (!isPathEnabled(extensionContext, denoJsonPath)) { + return; + } + const scopePath = path.dirname(denoJsonPath) + path.sep; + const tsConfigPath = path.join(scopePath, "tsconfig.json"); + const tsConfigPathsWithPromptHidden = context.globalState.get( + "deno.tsConfigPathsWithPromptHidden", + ) ?? []; + if (tsConfigPathsWithPromptHidden?.includes?.(tsConfigPath)) { + return; + } + let tsConfigContent; + try { + const tsConfigText = await fs.promises.readFile(tsConfigPath, { + encoding: "utf8", + }); + tsConfigContent = jsoncParser.parse(tsConfigText); + } catch { + return; + } + const compilerOptions = tsConfigContent?.compilerOptions; + if (!isObject(compilerOptions)) { + return; + } + for (const key of UNSUPPORTED_COMMON_COMPILER_OPTIONS) { + delete compilerOptions[key]; + } + if (Object.entries(compilerOptions).length == 0) { + return; + } + const plugins = compilerOptions?.plugins; + let selection; + if (Array.isArray(plugins) && plugins.length) { + // This tsconfig.json contains plugins. Prompt to disable the LSP. + const workspaceFolders = vscode.workspace.workspaceFolders ?? []; + let scopeFolderEntry = null; + const folderEntries = workspaceFolders.map((f) => + [f.uri.fsPath + path.sep, f] as const + ); + folderEntries.sort(); + folderEntries.reverse(); + for (const folderEntry of folderEntries) { + if (scopePath.startsWith(folderEntry[0])) { + scopeFolderEntry = folderEntry; + break; + } + } + if (!scopeFolderEntry) { + return; + } + const [scopeFolderPath, scopeFolder] = scopeFolderEntry; + selection = await vscode.window.showInformationMessage( + `A tsconfig.json with compiler options was discovered in a Deno-enabled folder. For projects with compiler plugins, it is recommended to disable the Deno language server if you are seeing errors (${tsConfigPath}).`, + "Disable Deno LSP", + "Hide this message", + ); + if (selection == "Disable Deno LSP") { + const config = vscode.workspace.getConfiguration( + EXTENSION_NS, + scopeFolder, + ); + if (scopePath == scopeFolderPath) { + await config.update("enable", false); + } else { + let disablePaths = config.get("disablePaths"); + if (!Array.isArray(disablePaths)) { + disablePaths = []; + } + const relativeUri = scopePath.substring(scopeFolderPath.length).replace( + /\\/g, + "/", + ).replace(/\/*$/, ""); + disablePaths.push(relativeUri); + await config.update("disablePaths", disablePaths); + } + } + } else { + // This tsconfig.json has compiler options which may be copied to a + // deno.json. If the deno.json has no compiler options, prompt to copy them + // over. + let denoJsonText; + let denoJsonContent; + try { + denoJsonText = await fs.promises.readFile(denoJsonPath, { + encoding: "utf8", + }); + denoJsonContent = jsoncParser.parse(denoJsonText); + } catch { + return; + } + if (!isObject(denoJsonContent) || "compilerOptions" in denoJsonContent) { + return; + } + selection = await vscode.window.showInformationMessage( + `A tsconfig.json with compiler options was discovered in a Deno-enabled folder. Would you like to copy these to your Deno configuration file? Note that only a subset of options are supported (${tsConfigPath}).`, + "Copy to deno.json[c]", + "Hide this message", + ); + if (selection == "Copy to deno.json[c]") { + try { + const editResult = jsoncParser.modify( + denoJsonText, + ["compilerOptions"], + compilerOptions, + { formattingOptions: { insertSpaces: true, tabSize: 2 } }, + ); + const newDenoJsonContent = jsoncParser.applyEdits( + denoJsonText, + editResult, + ); + await fs.promises.writeFile(denoJsonPath, newDenoJsonContent); + } catch (error) { + vscode.window.showErrorMessage( + `Could not modify "${denoJsonPath}": ${error}`, + ); + } + } + } + if (selection == "Hide this message") { + const tsConfigPathsWithPromptHidden = context.globalState.get( + "deno.tsConfigPathsWithPromptHidden", + ) ?? []; + tsConfigPathsWithPromptHidden?.push?.(tsConfigPath); + context.globalState.update( + "deno.tsConfigPathsWithPromptHidden", + tsConfigPathsWithPromptHidden, + ); + } +} + export function showReferences( _content: vscode.ExtensionContext, extensionContext: DenoExtensionContext, @@ -367,7 +531,7 @@ export function test( ): Callback { return async (uriStr: string, name: string, options: TestCommandOptions) => { const uri = vscode.Uri.parse(uriStr, true); - const path = uri.fsPath; + const filePath = uri.fsPath; const config = vscode.workspace.getConfiguration(EXTENSION_NS, uri); const testArgs: string[] = [ ...(config.get("codeLens.testArgs") ?? []), @@ -393,9 +557,9 @@ export function test( const denoEnvFile = config.get("envFile"); if (denoEnvFile) { if (workspaceFolder) { - const denoEnvPath = join(workspaceFolder.uri.fsPath, denoEnvFile); + const denoEnvPath = path.join(workspaceFolder.uri.fsPath, denoEnvFile); try { - const content = readFileSync(denoEnvPath, { encoding: "utf8" }); + const content = fs.readFileSync(denoEnvPath, { encoding: "utf8" }); const parsed = dotenv.parse(content); Object.assign(env, parsed); } catch (error) { @@ -417,7 +581,7 @@ export function test( env["DENO_FUTURE"] = "1"; } const nameRegex = `/^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$/`; - const args = ["test", ...testArgs, "--filter", nameRegex, path]; + const args = ["test", ...testArgs, "--filter", nameRegex, filePath]; const definition: tasks.DenoTaskDefinition = { type: tasks.TASK_TYPE, @@ -518,3 +682,41 @@ function isDenoDisabledCompletely(): boolean { vscode.workspace.getConfiguration(EXTENSION_NS, f) ).every(isScopeDisabled); } + +const UNSUPPORTED_COMMON_COMPILER_OPTIONS = [ + "baseUrl", + "composite", + "declaration", + "declarationDir", + "declarationMap", + "emitBOM", + "emitDeclarationOnly", + "emitDecoratorMetadata", + "generateCpuProfile", + "importHelpers", + "incremental", + "inlineSourceMap", + "inlineSources", + "moduleResolution", + "newLine", + "noEmit", + "noEmitHelpers", + "noEmitOnError", + "outDir", + "outFile", + "paths", + "preserveSymlinks", + "preserveWatchOutput", + "removeComments", + "rootDir", + "rootDirs", + "skipLibCheck", + "sourceMap", + "sourceRoot", + "stripInternal", + "tsBuildInfoFile", + "typeRoots", + "watch", + "watchDirectory", + "watchFile", +]; diff --git a/client/src/enable.ts b/client/src/enable.ts index 71f44f7b..0296e167 100644 --- a/client/src/enable.ts +++ b/client/src/enable.ts @@ -4,6 +4,8 @@ import { ENABLE, ENABLE_PATHS, EXTENSION_NS } from "./constants"; import * as vscode from "vscode"; import { DenoExtensionContext, EnableSettings } from "./types"; +import * as os from "os"; +import * as path from "path"; export interface WorkspaceEnabledInfo { folder: vscode.WorkspaceFolder; @@ -11,6 +13,45 @@ export interface WorkspaceEnabledInfo { hasDenoConfig: boolean; } +const PARENT_RELATIVE_REGEX = os.platform() === "win32" + ? /\.\.(?:[/\\]|$)/ + : /\.\.(?:\/|$)/; + +/** Checks if `parent` is an ancestor of `child`. */ +function pathStartsWith(child: string, parent: string) { + if (path.isAbsolute(child) !== path.isAbsolute(parent)) { + return false; + } + const relative = path.relative(parent, child); + return !relative.match(PARENT_RELATIVE_REGEX); +} + +export function isPathEnabled( + extensionContext: DenoExtensionContext, + filePath: string, +) { + const enableSettings = + extensionContext.enableSettingsByFolder?.find(([workspace, _]) => + pathStartsWith(filePath, workspace) + )?.[1] ?? extensionContext.enableSettingsUnscoped ?? + { enable: null, enablePaths: null, disablePaths: [] }; + const scopesWithDenoJson = extensionContext.scopesWithDenoJson ?? new Set(); + for (const path of enableSettings.disablePaths) { + if (pathStartsWith(filePath, path)) { + return false; + } + } + if (enableSettings.enablePaths) { + return enableSettings.enablePaths.some((p) => pathStartsWith(filePath, p)); + } + if (enableSettings.enable != null) { + return enableSettings.enable; + } + return [...scopesWithDenoJson].some((scope) => + pathStartsWith(filePath, scope) + ); +} + export async function getWorkspacesEnabledInfo() { const result: WorkspaceEnabledInfo[] = []; for (const folder of vscode.workspace.workspaceFolders ?? []) { diff --git a/client/src/extension.ts b/client/src/extension.ts index 8fe36e25..f904ea96 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -227,6 +227,10 @@ export async function activate( "deno.client.cacheActiveDocument", commands.cacheActiveDocument, ); + registerCommand( + "deno.client.clearHiddenPromptStorage", + commands.clearHiddenPromptStorage, + ); registerCommand("deno.client.restart", commands.startLanguageServer); registerCommand("deno.client.info", commands.info); registerCommand("deno.client.status", commands.status); diff --git a/package.json b/package.json index f5bc8df6..eea87416 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "onCommand:deno.client.welcome", "onCommand:deno.client.enable", "onCommand:deno.client.disable", + "onCommand:deno.client.clearHiddenPromptStorage", "onCommand:deno.reloadImportRegistries", "onWebviewPanel:welcomeDeno" ], @@ -87,6 +88,13 @@ "description": "Cache the active workspace document and its dependencies.", "enablement": "deno:lspReady" }, + { + "command": "deno.client.clearHiddenPromptStorage", + "title": "Clear Hidden Prompt Storage", + "category": "Deno", + "description": "Undo all 'hide this message' actions.", + "enablement": "deno:lspReady" + }, { "command": "deno.reloadImportRegistries", "title": "Reload Import Registries Cache",