Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: helper prompts for tsconfig.json compiler options #1166

Merged
merged 3 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
222 changes: 212 additions & 10 deletions client/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -129,9 +139,9 @@ export function startLanguageServer(
const denoEnvFile = config.get<string>("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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -253,6 +265,13 @@ export function startLanguageServer(
extensionContext.tsApi?.refresh();
}
extensionContext.tasksSidebar.refresh();
for (const addedDenoJsonUri of addedDenoJsonUris) {
await maybeShowTsConfigPrompt(
context,
extensionContext,
addedDenoJsonUri,
);
}
},
),
);
Expand Down Expand Up @@ -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<string[]>(
"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<string[]>("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<string[]>(
"deno.tsConfigPathsWithPromptHidden",
) ?? [];
tsConfigPathsWithPromptHidden?.push?.(tsConfigPath);
context.globalState.update(
"deno.tsConfigPathsWithPromptHidden",
tsConfigPathsWithPromptHidden,
);
}
}

export function showReferences(
_content: vscode.ExtensionContext,
extensionContext: DenoExtensionContext,
Expand Down Expand Up @@ -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<string[]>("codeLens.testArgs") ?? []),
Expand All @@ -393,9 +557,9 @@ export function test(
const denoEnvFile = config.get<string>("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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Comment on lines +687 to +721
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like the idea of having another list of compiler options here, but I can't think of anything else and this is only used for the conversion.

];
41 changes: 41 additions & 0 deletions client/src/enable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,54 @@ 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;
enabled: boolean | undefined;
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 ?? []) {
Expand Down
4 changes: 4 additions & 0 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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",
Expand Down
Loading