From 81c83ddbba398511d9541f0077c07dd23b911447 Mon Sep 17 00:00:00 2001 From: Rodge Fu Date: Thu, 31 Oct 2024 23:27:23 +0800 Subject: [PATCH] Restart Typespec Language Server when it's not started before or settings changed (#4912) 1. TypeSpec Language Server would be restarted with new settings when setting "typespec.tsp-server.path" is changed 2. Typespec Language Server can be restarted properly when the server wasn't running before 3. Code refactor in vscode extension. related issues: #2996, #4765 --------- Co-authored-by: Timothee Guerin --- .../vscode-restart-lsp-2024-9-30-20-29-32.md | 7 + .../vscode-restart-lsp-2024-9-31-11-4-2.md | 7 + packages/typespec-vscode/package.json | 2 +- packages/typespec-vscode/rollup.config.ts | 18 +- packages/typespec-vscode/src/const.ts | 3 + .../typespec-vscode/src/extension-logger.ts | 88 ---- packages/typespec-vscode/src/extension.ts | 386 ++---------------- .../src/log/console-log-listener.ts | 32 ++ .../src/log/extension-log-listener.ts | 51 +++ packages/typespec-vscode/src/log/logger.ts | 69 ++++ .../{ => log}/typespec-log-output-channel.ts | 0 packages/typespec-vscode/src/task-provider.ts | 115 ++++++ .../src/tsp-executable-resolver.ts | 132 ++++++ .../src/tsp-language-client.ts | 124 ++++++ packages/typespec-vscode/src/utils.ts | 55 ++- .../src/vscode-variable-resolver.ts | 20 + packages/typespec-vscode/src/web/extension.ts | 7 +- .../test/unit/extension.test.ts | 16 + .../test/{ => web}/data/basic.tsp | 0 .../typespec-vscode/test/{ => web}/suite.ts | 0 .../test/{ => web}/web.test.ts | 0 packages/typespec-vscode/vitest.config.ts | 11 + 22 files changed, 690 insertions(+), 453 deletions(-) create mode 100644 .chronus/changes/vscode-restart-lsp-2024-9-30-20-29-32.md create mode 100644 .chronus/changes/vscode-restart-lsp-2024-9-31-11-4-2.md create mode 100644 packages/typespec-vscode/src/const.ts delete mode 100644 packages/typespec-vscode/src/extension-logger.ts create mode 100644 packages/typespec-vscode/src/log/console-log-listener.ts create mode 100644 packages/typespec-vscode/src/log/extension-log-listener.ts create mode 100644 packages/typespec-vscode/src/log/logger.ts rename packages/typespec-vscode/src/{ => log}/typespec-log-output-channel.ts (100%) create mode 100644 packages/typespec-vscode/src/task-provider.ts create mode 100644 packages/typespec-vscode/src/tsp-executable-resolver.ts create mode 100644 packages/typespec-vscode/src/tsp-language-client.ts create mode 100644 packages/typespec-vscode/src/vscode-variable-resolver.ts create mode 100644 packages/typespec-vscode/test/unit/extension.test.ts rename packages/typespec-vscode/test/{ => web}/data/basic.tsp (100%) rename packages/typespec-vscode/test/{ => web}/suite.ts (100%) rename packages/typespec-vscode/test/{ => web}/web.test.ts (100%) create mode 100644 packages/typespec-vscode/vitest.config.ts diff --git a/.chronus/changes/vscode-restart-lsp-2024-9-30-20-29-32.md b/.chronus/changes/vscode-restart-lsp-2024-9-30-20-29-32.md new file mode 100644 index 0000000000..46db2dde60 --- /dev/null +++ b/.chronus/changes/vscode-restart-lsp-2024-9-30-20-29-32.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - typespec-vscode +--- + +Fix the issue when Typespec Language Server can't be restarted when the server wasn't running before diff --git a/.chronus/changes/vscode-restart-lsp-2024-9-31-11-4-2.md b/.chronus/changes/vscode-restart-lsp-2024-9-31-11-4-2.md new file mode 100644 index 0000000000..bcf424fefc --- /dev/null +++ b/.chronus/changes/vscode-restart-lsp-2024-9-31-11-4-2.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - typespec-vscode +--- + +TypeSpec Language Server would be restarted with new settings when setting "typespec.tsp-server.path" is changed \ No newline at end of file diff --git a/packages/typespec-vscode/package.json b/packages/typespec-vscode/package.json index 0d8a24fcc0..bdc4d12d5c 100644 --- a/packages/typespec-vscode/package.json +++ b/packages/typespec-vscode/package.json @@ -164,7 +164,7 @@ "deploy": "vsce publish", "open-in-browser": "vscode-test-web --extensionDevelopmentPath=. .", "test:e2e": "pnpm test:web", - "test:web": "vscode-test-web --extensionDevelopmentPath=. --headless --extensionTestsPath=dist/test/suite.js ./test/data" + "test:web": "vscode-test-web --extensionDevelopmentPath=. --headless --extensionTestsPath=dist/test/web/suite.js ./test/web/data" }, "devDependencies": { "@rollup/plugin-commonjs": "~28.0.0", diff --git a/packages/typespec-vscode/rollup.config.ts b/packages/typespec-vscode/rollup.config.ts index ee14741ca9..3d2f272757 100644 --- a/packages/typespec-vscode/rollup.config.ts +++ b/packages/typespec-vscode/rollup.config.ts @@ -1,8 +1,11 @@ import commonjs from "@rollup/plugin-commonjs"; import resolve from "@rollup/plugin-node-resolve"; import typescript from "@rollup/plugin-typescript"; +import { dirname } from "path"; import { defineConfig } from "rollup"; +import { fileURLToPath } from "url"; +const projDir = dirname(fileURLToPath(import.meta.url)); const plugins = [(resolve as any)({ preferBuiltins: true }), (commonjs as any)()]; const baseConfig = defineConfig({ @@ -53,17 +56,24 @@ export default defineConfig([ }, { ...baseConfig, - input: "test/suite.ts", + input: "test/web/suite.ts", output: { - file: "dist/test/suite.js", // VSCode web will add extra .js if you use .cjs + file: "dist/test/web/suite.js", // VSCode web will add extra .js if you use .cjs format: "commonjs", sourcemap: true, inlineDynamicImports: true, }, - plugins: [...plugins, ts("dist/test")], + plugins: [...plugins, ts("dist/test/web")], }, ]); function ts(outDir: string) { - return (typescript as any)({ tsconfig: "./tsconfig.build.json", outDir }); + return (typescript as any)({ + compilerOptions: { + // set sourceRoot to absolute path, otherwise the path in the map file generated is incorrect when outDir is given + sourceRoot: projDir, + }, + tsconfig: "./tsconfig.build.json", + outDir, + }); } diff --git a/packages/typespec-vscode/src/const.ts b/packages/typespec-vscode/src/const.ts new file mode 100644 index 0000000000..c2d3e7cec6 --- /dev/null +++ b/packages/typespec-vscode/src/const.ts @@ -0,0 +1,3 @@ +export const enum SettingName { + TspServerPath = "typespec.tsp-server.path", +} diff --git a/packages/typespec-vscode/src/extension-logger.ts b/packages/typespec-vscode/src/extension-logger.ts deleted file mode 100644 index 897d7bdf8d..0000000000 --- a/packages/typespec-vscode/src/extension-logger.ts +++ /dev/null @@ -1,88 +0,0 @@ -import vscode, { LogOutputChannel } from "vscode"; - -type Progress = vscode.Progress<{ - message?: string; - increment?: number; -}>; - -interface LogOptions { - /** show the Output window in vscode */ - showOutput: boolean; - /** show the log in vscode popup */ - showPopup: boolean; - /** update the progress with the log */ - progress?: Progress; -} - -export class ExtensionLogger { - constructor(public outputChannel?: LogOutputChannel) {} - - private logInternal( - msg: string, - details?: any[], - options?: LogOptions, - logAction?: (msg: string, ...args: any[]) => void, - popupAction?: (msg: string, ...items: string[]) => Thenable, - ) { - const VIEW_DETAIL_IN_OUTPUT = "View details in Output"; - const { showOutput, showPopup, progress } = options ?? { showOutput: false, showPopup: false }; - if (logAction) logAction(msg, details ?? []); - if (showOutput && this.outputChannel) { - this.outputChannel.show(true /*preserveFocus*/); - } - - if (showPopup && popupAction) { - void popupAction(msg, VIEW_DETAIL_IN_OUTPUT).then((value) => { - if (value === VIEW_DETAIL_IN_OUTPUT) { - this.outputChannel?.show(true /*preserveFocus*/); - } - }); - } - if (progress) { - progress.report({ message: msg }); - } - } - - info(message: string, details?: any[], options?: LogOptions): void { - this.logInternal( - message, - details, - options, - (m, d) => this.outputChannel?.info(m, ...d), - vscode.window.showInformationMessage, - ); - } - - warning(message: string, details?: any[], options?: LogOptions) { - this.logInternal( - message, - details, - options, - (m, d) => this.outputChannel?.warn(m, ...d), - vscode.window.showWarningMessage, - ); - } - - error(message: string, details?: any[], options?: LogOptions) { - this.logInternal( - message, - details, - options, - (m, d) => this.outputChannel?.error(m, ...d), - vscode.window.showErrorMessage, - ); - } - - debug(message: string, details?: any[], options?: LogOptions) { - this.logInternal( - message, - details, - options, - (m, d) => this.outputChannel?.debug(m, ...d), - vscode.window.showInformationMessage, - ); - } -} - -const logger = new ExtensionLogger(); -export default logger; diff --git a/packages/typespec-vscode/src/extension.ts b/packages/typespec-vscode/src/extension.ts index fa69cdcc7b..3647a203f6 100644 --- a/packages/typespec-vscode/src/extension.ts +++ b/packages/typespec-vscode/src/extension.ts @@ -1,24 +1,18 @@ -import { ResolveModuleHost } from "@typespec/compiler/module-resolver"; -import { readFile, realpath, stat } from "fs/promises"; -import { dirname, isAbsolute, join, resolve } from "path"; -import vscode, { ExtensionContext, RelativePattern, commands, workspace } from "vscode"; -import { - Executable, - ExecutableOptions, - LanguageClient, - LanguageClientOptions, -} from "vscode-languageclient/node.js"; -import logger from "./extension-logger.js"; -import { TypeSpecLogOutputChannel } from "./typespec-log-output-channel.js"; -import { listParentFolder, normalizeSlash, useShellInExec } from "./utils.js"; - -let client: LanguageClient | undefined; +import vscode, { commands, ExtensionContext } from "vscode"; +import { SettingName } from "./const.js"; +import { ExtensionLogListener } from "./log/extension-log-listener.js"; +import logger from "./log/logger.js"; +import { TypeSpecLogOutputChannel } from "./log/typespec-log-output-channel.js"; +import { createTaskProvider } from "./task-provider.js"; +import { TspLanguageClient } from "./tsp-language-client.js"; + +let client: TspLanguageClient | undefined; /** * Workaround: LogOutputChannel doesn't work well with LSP RemoteConsole, so having a customized LogOutputChannel to make them work together properly * More detail can be found at https://github.com/microsoft/vscode-discussions/discussions/1149 */ const outputChannel = new TypeSpecLogOutputChannel("TypeSpec"); -logger.outputChannel = outputChannel; +logger.registerLogListener("extension-log", new ExtensionLogListener(outputChannel)); export async function activate(context: ExtensionContext) { context.subscriptions.push(createTaskProvider()); @@ -30,7 +24,23 @@ export async function activate(context: ExtensionContext) { ); context.subscriptions.push( - commands.registerCommand("typespec.restartServer", restartTypeSpecServer), + commands.registerCommand("typespec.restartServer", async () => { + if (client) { + await client.restart(); + } + }), + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => { + if (e.affectsConfiguration(SettingName.TspServerPath)) { + logger.info("TypeSpec server path changed, restarting server..."); + const oldClient = client; + client = await TspLanguageClient.create(context, outputChannel); + await oldClient?.stop(); + await client.start(); + } + }), ); return await vscode.window.withProgress( @@ -38,349 +48,13 @@ export async function activate(context: ExtensionContext) { title: "Launching TypeSpec language service...", location: vscode.ProgressLocation.Notification, }, - async () => launchLanguageClient(context), - ); -} - -function createTaskProvider() { - return vscode.tasks.registerTaskProvider("typespec", { - provideTasks: async () => { - logger.info("Providing tsp tasks"); - const targetPathes = await vscode.workspace - .findFiles("**/main.tsp", "**/node_modules/**") - .then((uris) => - uris - .filter((uri) => uri.scheme === "file" && !uri.fsPath.includes("node_modules")) - .map((uri) => normalizeSlash(uri.fsPath)), - ); - logger.info(`Found ${targetPathes.length} main.tsp files`); - const tasks: vscode.Task[] = []; - for (const targetPath of targetPathes) { - tasks.push(...(await createBuiltInTasks(targetPath))); - } - logger.info(`Provided ${tasks.length} tsp tasks`); - return tasks; + async () => { + client = await TspLanguageClient.create(context, outputChannel); + await client.start(); }, - resolveTask: async (task: vscode.Task): Promise => { - if (task.definition.type === "typespec" && task.name && task.definition.path) { - const t = await createTask(task.name, task.definition.path, task.definition.args); - if (t) { - // returned task's definition must be the same object as the given task's definition - // otherwise vscode would report error that the task is not resolved - t.definition = task.definition; - return t; - } else { - return undefined; - } - } - return undefined; - }, - }); -} - -function getTaskPath(targetPath: string): { absoluteTargetPath: string; workspaceFolder: string } { - let workspaceFolder = workspace.getWorkspaceFolder(vscode.Uri.file(targetPath))?.uri.fsPath; - if (!workspaceFolder) { - workspaceFolder = workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""; - logger.warning( - `Can't resolve workspace folder from given file ${targetPath}. Try to use the first workspace folder ${workspaceFolder}.`, - ); - } - const variableResolver = new VSCodeVariableResolver({ - workspaceFolder, - workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. - }); - targetPath = variableResolver.resolve(targetPath); - targetPath = resolve(workspaceFolder, targetPath); - targetPath = normalizeSlash(variableResolver.resolve(targetPath)); - return { absoluteTargetPath: targetPath, workspaceFolder }; -} - -function createTaskInternal( - name: string, - absoluteTargetPath: string, - args: string, - cli: Executable, - workspaceFolder: string, -) { - let cmd = `${cli.command} ${cli.args?.join(" ") ?? ""} compile "${absoluteTargetPath}" ${args}`; - const variableResolver = new VSCodeVariableResolver({ - workspaceFolder, - workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. - }); - cmd = variableResolver.resolve(cmd); - logger.debug( - `Command of tsp compile task "${name}" is resolved to: ${cmd} with cwd "${workspaceFolder}"`, - ); - return new vscode.Task( - { - type: "typespec", - path: absoluteTargetPath, - args: args, - }, - vscode.TaskScope.Workspace, - name, - "tsp", - workspaceFolder - ? new vscode.ShellExecution(cmd, { cwd: workspaceFolder }) - : new vscode.ShellExecution(cmd), ); } -async function createTask(name: string, targetPath: string, args?: string) { - const { absoluteTargetPath, workspaceFolder } = getTaskPath(targetPath); - const cli = await resolveTypeSpecCli(absoluteTargetPath); - if (!cli) { - return undefined; - } - return await createTaskInternal(name, absoluteTargetPath, args ?? "", cli, workspaceFolder); -} - -async function createBuiltInTasks(targetPath: string): Promise { - const { absoluteTargetPath, workspaceFolder } = getTaskPath(targetPath); - const cli = await resolveTypeSpecCli(absoluteTargetPath); - if (!cli) { - return []; - } - return [ - { name: `compile - ${targetPath}`, args: "" }, - { name: `watch - ${targetPath}`, args: "--watch" }, - ].map(({ name, args }) => { - return createTaskInternal(name, absoluteTargetPath, args, cli, workspaceFolder); - }); -} - -async function restartTypeSpecServer(): Promise { - if (client) { - await client.stop(); - await client.start(); - logger.debug("TypeSpec server restarted"); - } -} - -async function launchLanguageClient(context: ExtensionContext) { - const exe = await resolveTypeSpecServer(context); - logger.debug("TypeSpec server resolved as ", [exe]); - const watchers = [ - workspace.createFileSystemWatcher("**/*.cadl"), - workspace.createFileSystemWatcher("**/cadl-project.yaml"), - workspace.createFileSystemWatcher("**/*.tsp"), - workspace.createFileSystemWatcher("**/tspconfig.yaml"), - // please be aware that the vscode watch with '**' will honer the files.watcherExclude settings - // so we won't get notification for those package.json under node_modules - // if our customers exclude the node_modules folder in files.watcherExclude settings. - workspace.createFileSystemWatcher("**/package.json"), - ]; - for (const folder of vscode.workspace.workspaceFolders ?? []) { - for (const p of listParentFolder(folder.uri.fsPath, false /*includeSelf*/)) { - watchers.push(workspace.createFileSystemWatcher(new RelativePattern(p, "package.json"))); - } - } - watchers.forEach((w) => context.subscriptions.push(w)); - const options: LanguageClientOptions = { - synchronize: { - // Synchronize the setting section 'typespec' to the server - configurationSection: "typespec", - fileEvents: watchers, - }, - documentSelector: [ - { scheme: "file", language: "typespec" }, - { scheme: "untitled", language: "typespec" }, - { scheme: "file", language: "yaml", pattern: "**/tspconfig.yaml" }, - ], - outputChannel, - }; - - const name = "TypeSpec"; - const id = "typespec"; - try { - client = new LanguageClient(id, name, { run: exe, debug: exe }, options); - await client.start(); - logger.debug("TypeSpec server started"); - } catch (e) { - if (typeof e === "string" && e.startsWith("Launching server using command")) { - const workspaceFolder = workspace.workspaceFolders?.[0]?.uri?.fsPath ?? ""; - - logger.error( - [ - `TypeSpec server executable was not found: '${exe.command}' is not found. Make sure either:`, - ` - TypeSpec is installed locally at the root of this workspace ("${workspaceFolder}") or in a parent directory.`, - " - TypeSpec is installed globally with `npm install -g @typespec/compiler'.", - " - TypeSpec server path is configured with https://github.com/microsoft/typespec#installing-vs-code-extension.", - ].join("\n"), - [], - { showOutput: false, showPopup: true }, - ); - logger.error("Error detail", [e]); - throw `TypeSpec server executable was not found: '${exe.command}' is not found.`; - } else { - throw e; - } - } -} - -/** - * - * @param absoluteTargetPath the path is expected to be absolute path and no further expanding or resolving needed. - * @returns - */ -async function resolveTypeSpecCli(absoluteTargetPath: string): Promise { - if (!isAbsolute(absoluteTargetPath)) { - logger.error(`Expect absolute path for resolving cli, but got ${absoluteTargetPath}`); - return undefined; - } - - const options: ExecutableOptions = { - env: { ...process.env }, - }; - - const baseDir = (await isFile(absoluteTargetPath)) - ? dirname(absoluteTargetPath) - : absoluteTargetPath; - - const compilerPath = await resolveLocalCompiler(baseDir); - if (!compilerPath || compilerPath.length === 0) { - const executable = process.platform === "win32" ? `tsp.cmd` : "tsp"; - logger.debug( - `Can't resolve compiler path for tsp task, try to use default value ${executable}.`, - ); - return useShellInExec({ command: executable, args: [], options }); - } else { - logger.debug(`Compiler path resolved as: ${compilerPath}`); - const jsPath = join(compilerPath, "cmd/tsp.js"); - options.env["TYPESPEC_SKIP_COMPILER_RESOLVE"] = "1"; - return { command: "node", args: [jsPath], options }; - } -} - -async function resolveTypeSpecServer(context: ExtensionContext): Promise { - const nodeOptions = process.env.TYPESPEC_SERVER_NODE_OPTIONS; - const args = ["--stdio"]; - - // In development mode (F5 launch from source), resolve to locally built server.js. - if (process.env.TYPESPEC_DEVELOPMENT_MODE) { - const script = context.asAbsolutePath("../compiler/entrypoints/server.js"); - // we use CLI instead of NODE_OPTIONS environment variable in this case - // because --nolazy is not supported by NODE_OPTIONS. - const options = nodeOptions?.split(" ").filter((o) => o) ?? []; - logger.debug("TypeSpec server resolved in development mode"); - return { command: "node", args: [...options, script, ...args] }; - } - - const options: ExecutableOptions = { - env: { ...process.env }, - }; - if (nodeOptions) { - options.env.NODE_OPTIONS = nodeOptions; - } - - // In production, first try VS Code configuration, which allows a global machine - // location that is not on PATH, or a workspace-specific installation. - let serverPath: string | undefined = workspace.getConfiguration().get("typespec.tsp-server.path"); - if (serverPath && typeof serverPath !== "string") { - throw new Error("VS Code configuration option 'typespec.tsp-server.path' must be a string"); - } - const workspaceFolder = workspace.workspaceFolders?.[0]?.uri?.fsPath ?? ""; - - // Default to tsp-server on PATH, which would come from `npm install -g - // @typespec/compiler` in a vanilla setup. - if (serverPath) { - logger.debug(`Server path loaded from VS Code configuration: ${serverPath}`); - } else { - serverPath = await resolveLocalCompiler(workspaceFolder); - } - if (!serverPath) { - const executable = process.platform === "win32" ? "tsp-server.cmd" : "tsp-server"; - logger.debug(`Can't resolve server path, try to use default value ${executable}.`); - return useShellInExec({ command: executable, args, options }); - } - const variableResolver = new VSCodeVariableResolver({ - workspaceFolder, - workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. - }); - - serverPath = variableResolver.resolve(serverPath); - logger.debug(`Server path expanded to: ${serverPath}`); - - if (!serverPath.endsWith(".js")) { - // Allow path to tsp-server.cmd to be passed. - if (await isFile(serverPath)) { - const command = - process.platform === "win32" && !serverPath.endsWith(".cmd") - ? `${serverPath}.cmd` - : serverPath; - - return useShellInExec({ command, args, options }); - } else { - serverPath = join(serverPath, "cmd/tsp-server.js"); - } - } - - options.env["TYPESPEC_SKIP_COMPILER_RESOLVE"] = "1"; - return { command: "node", args: [serverPath, ...args], options }; -} - -async function resolveLocalCompiler(baseDir: string): Promise { - // dynamic import required when unbundled as this module is CommonJS for - // VS Code and the module-resolver is an ES module. - const { resolveModule } = await import("@typespec/compiler/module-resolver"); - - const host: ResolveModuleHost = { - realpath, - readFile: (path: string) => readFile(path, "utf-8"), - stat, - }; - try { - logger.debug(`Try to resolve compiler from local, baseDir: ${baseDir}`); - const executable = await resolveModule(host, "@typespec/compiler", { - baseDir, - }); - if (executable.type === "module") { - logger.debug(`Resolved compiler from local: ${executable.path}`); - return executable.path; - } else { - logger.debug( - `Failed to resolve compiler from local. Unexpected executable type: ${executable.type}`, - ); - } - } catch (e) { - // Couldn't find the module - logger.debug("Exception when resolving compiler from local", [e]); - return undefined; - } - return undefined; -} - -async function isFile(path: string) { - try { - const stats = await stat(path); - return stats.isFile(); - } catch { - return false; - } -} - export async function deactivate() { await client?.stop(); } - -/** - * Resolve some of the VSCode variables. - * Simpler aLternative until https://github.com/microsoft/vscode/issues/46471 is supported. - */ -class VSCodeVariableResolver { - static readonly VARIABLE_REGEXP = /\$\{([^{}]+?)\}/g; - - public constructor(private variables: Record) {} - - public resolve(value: string): string { - const replaced = value.replace( - VSCodeVariableResolver.VARIABLE_REGEXP, - (match: string, variable: string) => { - return this.variables[variable] ?? match; - }, - ); - - return replaced; - } -} diff --git a/packages/typespec-vscode/src/log/console-log-listener.ts b/packages/typespec-vscode/src/log/console-log-listener.ts new file mode 100644 index 0000000000..ebf4a28669 --- /dev/null +++ b/packages/typespec-vscode/src/log/console-log-listener.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-console */ +import { LogItem, LogListener } from "./logger.js"; + +export class ConsoleLogLogger implements LogListener { + Log(log: LogItem) { + const logToConsole = (log: LogItem, fn: (m?: any, ...p: any[]) => void) => { + log.details + ? fn(`[${log.level.toUpperCase()}] ${log.message}`, log.details) + : fn(`[${log.level.toUpperCase()}] ${log.message}`); + }; + switch (log.level) { + case "info": + logToConsole(log, console.log); + break; + case "warn": + logToConsole(log, console.warn); + break; + case "error": + logToConsole(log, console.error); + break; + case "debug": + logToConsole(log, console.debug); + break; + case "trace": + // only trace log when there is env var 'ENABLE_TRACE_LOG' set to 'true' to avoid too many logs + if (process.env.ENABLE_TRACE_LOG === "true") { + logToConsole(log, console.trace); + } + break; + } + } +} diff --git a/packages/typespec-vscode/src/log/extension-log-listener.ts b/packages/typespec-vscode/src/log/extension-log-listener.ts new file mode 100644 index 0000000000..bc793cfb2f --- /dev/null +++ b/packages/typespec-vscode/src/log/extension-log-listener.ts @@ -0,0 +1,51 @@ +import vscode from "vscode"; +import { LogItem, LogListener, LogOptions } from "./logger.js"; + +export interface ExtensionLogOptions extends LogOptions { + /** show the Output window in vscode */ + showOutput: boolean; + /** show the log in vscode popup */ + showPopup: boolean; +} + +export class ExtensionLogListener implements LogListener { + constructor(private outputChannel?: vscode.LogOutputChannel) {} + + Log(log: LogItem) { + const VIEW_DETAIL_IN_OUTPUT = "View details in Output"; + const { showOutput, showPopup } = log.options ?? { showOutput: false, showPopup: false }; + let popupAction: ((msg: string, ...items: string[]) => Thenable) | undefined; + switch (log.level) { + case "error": + this.outputChannel?.error(log.message, ...(log.details ?? [])); + popupAction = vscode.window.showErrorMessage; + break; + case "trace": + this.outputChannel?.trace(log.message, ...(log.details ?? [])); + break; + case "debug": + this.outputChannel?.debug(log.message, ...(log.details ?? [])); + popupAction = vscode.window.showInformationMessage; + break; + case "info": + this.outputChannel?.info(log.message, ...(log.details ?? [])); + popupAction = vscode.window.showInformationMessage; + break; + case "warn": + this.outputChannel?.warn(log.message, ...(log.details ?? [])); + popupAction = vscode.window.showWarningMessage; + break; + } + if (showOutput && this.outputChannel) { + this.outputChannel.show(true /*preserveFocus*/); + } + + if (showPopup && popupAction) { + void popupAction(log.message, VIEW_DETAIL_IN_OUTPUT).then((value) => { + if (value === VIEW_DETAIL_IN_OUTPUT) { + this.outputChannel?.show(true /*preserveFocus*/); + } + }); + } + } +} diff --git a/packages/typespec-vscode/src/log/logger.ts b/packages/typespec-vscode/src/log/logger.ts new file mode 100644 index 0000000000..1e33c5eb29 --- /dev/null +++ b/packages/typespec-vscode/src/log/logger.ts @@ -0,0 +1,69 @@ +export type LogLevel = "info" | "warn" | "error" | "debug" | "trace"; + +export type LogOptions = Record; +export interface LogItem { + message: string; + level: LogLevel; + details?: any[]; + options?: LogOptions; +} + +export interface LogListener { + Log(item: LogItem): void; +} + +export class Logger { + private _listeners: Map = new Map(); + + private logInternal(item: LogItem) { + this._listeners.forEach((listener) => { + listener.Log(item); + }); + } + + registerLogListener(name: string, listener: LogListener) { + this._listeners.set(name, listener); + } + + unregisterLogListener(name: string) { + this._listeners.delete(name); + } + + log(level: LogLevel, message: string, details?: any[], options?: LogOptions): void { + this.logInternal({ message, level, details, options }); + } + + error(message: string, details?: any[], options?: LogOptions): void { + this.logInternal({ message, level: "error", details, options }); + } + + warning(message: string, details?: any[], options?: LogOptions): void { + this.logInternal({ message, level: "warn", details, options }); + } + + info(message: string, details?: any[], options?: LogOptions): void { + this.logInternal({ message, level: "info", details, options }); + } + + debug(message: string, details?: any[], options?: LogOptions): void { + this.logInternal({ message, level: "debug", details, options }); + } + + trace(message: string, details?: any[], options?: LogOptions): void { + this.logInternal({ message, level: "trace", details, options }); + } + + async profile(actionName: string, action: () => Promise) { + const start = Date.now(); + try { + return await action(); + } finally { + const end = Date.now(); + const elapsed = end - start; + this.trace(`${actionName} took ${elapsed}ms`); + } + } +} + +const logger = new Logger(); +export default logger; diff --git a/packages/typespec-vscode/src/typespec-log-output-channel.ts b/packages/typespec-vscode/src/log/typespec-log-output-channel.ts similarity index 100% rename from packages/typespec-vscode/src/typespec-log-output-channel.ts rename to packages/typespec-vscode/src/log/typespec-log-output-channel.ts diff --git a/packages/typespec-vscode/src/task-provider.ts b/packages/typespec-vscode/src/task-provider.ts new file mode 100644 index 0000000000..08cd0a8355 --- /dev/null +++ b/packages/typespec-vscode/src/task-provider.ts @@ -0,0 +1,115 @@ +import { resolve } from "path"; +import vscode, { workspace } from "vscode"; +import { Executable } from "vscode-languageclient/node.js"; +import logger from "./log/logger.js"; +import { resolveTypeSpecCli } from "./tsp-executable-resolver.js"; +import { normalizeSlash } from "./utils.js"; +import { VSCodeVariableResolver } from "./vscode-variable-resolver.js"; + +export function createTaskProvider() { + return vscode.tasks.registerTaskProvider("typespec", { + provideTasks: async () => { + logger.info("Providing tsp tasks"); + const targetPathes = await vscode.workspace + .findFiles("**/main.tsp", "**/node_modules/**") + .then((uris) => + uris + .filter((uri) => uri.scheme === "file" && !uri.fsPath.includes("node_modules")) + .map((uri) => normalizeSlash(uri.fsPath)), + ); + logger.info(`Found ${targetPathes.length} main.tsp files`); + const tasks: vscode.Task[] = []; + for (const targetPath of targetPathes) { + tasks.push(...(await createBuiltInTasks(targetPath))); + } + logger.info(`Provided ${tasks.length} tsp tasks`); + return tasks; + }, + resolveTask: async (task: vscode.Task): Promise => { + if (task.definition.type === "typespec" && task.name && task.definition.path) { + const t = await createTask(task.name, task.definition.path, task.definition.args); + if (t) { + // returned task's definition must be the same object as the given task's definition + // otherwise vscode would report error that the task is not resolved + t.definition = task.definition; + return t; + } else { + return undefined; + } + } + return undefined; + }, + }); +} + +function getTaskPath(targetPath: string): { absoluteTargetPath: string; workspaceFolder: string } { + let workspaceFolder = workspace.getWorkspaceFolder(vscode.Uri.file(targetPath))?.uri.fsPath; + if (!workspaceFolder) { + workspaceFolder = workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""; + logger.warning( + `Can't resolve workspace folder from given file ${targetPath}. Try to use the first workspace folder ${workspaceFolder}.`, + ); + } + const variableResolver = new VSCodeVariableResolver({ + workspaceFolder, + workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. + }); + targetPath = variableResolver.resolve(targetPath); + targetPath = resolve(workspaceFolder, targetPath); + targetPath = normalizeSlash(variableResolver.resolve(targetPath)); + return { absoluteTargetPath: targetPath, workspaceFolder }; +} + +function createTaskInternal( + name: string, + absoluteTargetPath: string, + args: string, + cli: Executable, + workspaceFolder: string, +) { + let cmd = `${cli.command} ${cli.args?.join(" ") ?? ""} compile "${absoluteTargetPath}" ${args}`; + const variableResolver = new VSCodeVariableResolver({ + workspaceFolder, + workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. + }); + cmd = variableResolver.resolve(cmd); + logger.debug( + `Command of tsp compile task "${name}" is resolved to: ${cmd} with cwd "${workspaceFolder}"`, + ); + return new vscode.Task( + { + type: "typespec", + path: absoluteTargetPath, + args: args, + }, + vscode.TaskScope.Workspace, + name, + "tsp", + workspaceFolder + ? new vscode.ShellExecution(cmd, { cwd: workspaceFolder }) + : new vscode.ShellExecution(cmd), + ); +} + +async function createTask(name: string, targetPath: string, args?: string) { + const { absoluteTargetPath, workspaceFolder } = getTaskPath(targetPath); + const cli = await resolveTypeSpecCli(absoluteTargetPath); + if (!cli) { + return undefined; + } + return await createTaskInternal(name, absoluteTargetPath, args ?? "", cli, workspaceFolder); +} + +async function createBuiltInTasks(targetPath: string): Promise { + const { absoluteTargetPath, workspaceFolder } = getTaskPath(targetPath); + const cli = await resolveTypeSpecCli(absoluteTargetPath); + if (!cli) { + return []; + } + return [ + { name: `compile - ${targetPath}`, args: "" }, + { name: `watch - ${targetPath}`, args: "--watch" }, + ].map(({ name, args }) => { + return createTaskInternal(name, absoluteTargetPath, args, cli, workspaceFolder); + }); +} diff --git a/packages/typespec-vscode/src/tsp-executable-resolver.ts b/packages/typespec-vscode/src/tsp-executable-resolver.ts new file mode 100644 index 0000000000..ca61f89e30 --- /dev/null +++ b/packages/typespec-vscode/src/tsp-executable-resolver.ts @@ -0,0 +1,132 @@ +import { dirname, isAbsolute, join } from "path"; +import { ExtensionContext, workspace } from "vscode"; +import { Executable, ExecutableOptions } from "vscode-languageclient/node.js"; +import { SettingName } from "./const.js"; +import logger from "./log/logger.js"; +import { isFile, loadModule, useShellInExec } from "./utils.js"; +import { VSCodeVariableResolver } from "./vscode-variable-resolver.js"; + +/** + * + * @param absoluteTargetPath the path is expected to be absolute path and no further expanding or resolving needed. + * @returns + */ +export async function resolveTypeSpecCli( + absoluteTargetPath: string, +): Promise { + if (!isAbsolute(absoluteTargetPath)) { + logger.error(`Expect absolute path for resolving cli, but got ${absoluteTargetPath}`); + return undefined; + } + + const options: ExecutableOptions = { + env: { ...process.env }, + }; + + const baseDir = (await isFile(absoluteTargetPath)) + ? dirname(absoluteTargetPath) + : absoluteTargetPath; + + const compilerPath = await resolveLocalCompiler(baseDir); + if (!compilerPath || compilerPath.length === 0) { + const executable = process.platform === "win32" ? `tsp.cmd` : "tsp"; + logger.debug( + `Can't resolve compiler path for tsp task, try to use default value ${executable}.`, + ); + return useShellInExec({ command: executable, args: [], options }); + } else { + logger.debug(`Compiler path resolved as: ${compilerPath}`); + const jsPath = join(compilerPath, "cmd/tsp.js"); + options.env["TYPESPEC_SKIP_COMPILER_RESOLVE"] = "1"; + return { command: "node", args: [jsPath], options }; + } +} + +export async function resolveTypeSpecServer(context: ExtensionContext): Promise { + const nodeOptions = process.env.TYPESPEC_SERVER_NODE_OPTIONS; + const args = ["--stdio"]; + + // In development mode (F5 launch from source), resolve to locally built server.js. + if (process.env.TYPESPEC_DEVELOPMENT_MODE) { + const script = context.asAbsolutePath("../compiler/entrypoints/server.js"); + // we use CLI instead of NODE_OPTIONS environment variable in this case + // because --nolazy is not supported by NODE_OPTIONS. + const options = nodeOptions?.split(" ").filter((o) => o) ?? []; + logger.debug("TypeSpec server resolved in development mode"); + return { command: "node", args: [...options, script, ...args] }; + } + + const options: ExecutableOptions = { + env: { ...process.env }, + }; + if (nodeOptions) { + options.env.NODE_OPTIONS = nodeOptions; + } + + // In production, first try VS Code configuration, which allows a global machine + // location that is not on PATH, or a workspace-specific installation. + let serverPath: string | undefined = workspace.getConfiguration().get(SettingName.TspServerPath); + if (serverPath && typeof serverPath !== "string") { + throw new Error(`VS Code configuration option '${SettingName.TspServerPath}' must be a string`); + } + const workspaceFolder = workspace.workspaceFolders?.[0]?.uri?.fsPath ?? ""; + + // Default to tsp-server on PATH, which would come from `npm install -g + // @typespec/compiler` in a vanilla setup. + if (serverPath) { + logger.debug(`Server path loaded from VS Code configuration: ${serverPath}`); + } else { + serverPath = await resolveLocalCompiler(workspaceFolder); + } + if (!serverPath) { + const executable = process.platform === "win32" ? "tsp-server.cmd" : "tsp-server"; + logger.debug(`Can't resolve server path, try to use default value ${executable}.`); + return useShellInExec({ command: executable, args, options }); + } + const variableResolver = new VSCodeVariableResolver({ + workspaceFolder, + workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. + }); + + serverPath = variableResolver.resolve(serverPath); + logger.debug(`Server path expanded to: ${serverPath}`); + + if (!serverPath.endsWith(".js")) { + // Allow path to tsp-server.cmd to be passed. + if (await isFile(serverPath)) { + const command = + process.platform === "win32" && !serverPath.endsWith(".cmd") + ? `${serverPath}.cmd` + : serverPath; + + return useShellInExec({ command, args, options }); + } else { + serverPath = join(serverPath, "cmd/tsp-server.js"); + } + } + + options.env["TYPESPEC_SKIP_COMPILER_RESOLVE"] = "1"; + return { command: "node", args: [serverPath, ...args], options }; +} + +async function resolveLocalCompiler(baseDir: string): Promise { + try { + const executable = await loadModule(baseDir, "@typespec/compiler"); + if (!executable) { + return undefined; + } + if (executable.type === "module") { + logger.debug(`Resolved compiler from local: ${executable.path}`); + return executable.path; + } else { + logger.debug( + `Failed to resolve compiler from local. Unexpected executable type: ${executable.type}`, + ); + } + } catch (e) { + // Couldn't find the module + logger.debug("Exception when resolving compiler from local", [e]); + return undefined; + } + return undefined; +} diff --git a/packages/typespec-vscode/src/tsp-language-client.ts b/packages/typespec-vscode/src/tsp-language-client.ts new file mode 100644 index 0000000000..c3b9ec840c --- /dev/null +++ b/packages/typespec-vscode/src/tsp-language-client.ts @@ -0,0 +1,124 @@ +import { ExtensionContext, LogOutputChannel, RelativePattern, workspace } from "vscode"; +import { Executable, LanguageClient, LanguageClientOptions } from "vscode-languageclient/node.js"; +import logger from "./log/logger.js"; +import { resolveTypeSpecServer } from "./tsp-executable-resolver.js"; +import { listParentFolder } from "./utils.js"; + +export class TspLanguageClient { + constructor( + private client: LanguageClient, + private exe: Executable, + ) {} + + async restart(): Promise { + try { + if (this.client.needsStop()) { + await this.client.restart(); + logger.info("TypeSpec server restarted"); + } else if (this.client.needsStart()) { + await this.client.start(); + logger.info("TypeSpec server started"); + } else { + logger.error( + `Unexpected state when restarting TypeSpec server. state = ${this.client.state}.`, + ); + } + } catch (e) { + logger.error("Error restarting TypeSpec server", [e]); + } + } + + async stop(): Promise { + try { + if (this.client.needsStop()) { + await this.client.stop(); + logger.info("TypeSpec server stopped"); + } else { + logger.warning("TypeSpec server is already stopped"); + } + } catch (e) { + logger.error("Error stopping TypeSpec server", [e]); + } + } + + async start(): Promise { + try { + if (this.client.needsStart()) { + await this.client.start(); + logger.info("TypeSpec server started"); + } else { + logger.warning("TypeSpec server is already started"); + } + } catch (e) { + if (typeof e === "string" && e.startsWith("Launching server using command")) { + const workspaceFolder = workspace.workspaceFolders?.[0]?.uri?.fsPath ?? ""; + + logger.error( + [ + `TypeSpec server executable was not found: '${this.exe.command}' is not found. Make sure either:`, + ` - TypeSpec is installed locally at the root of this workspace ("${workspaceFolder}") or in a parent directory.`, + " - TypeSpec is installed globally with `npm install -g @typespec/compiler'.", + " - TypeSpec server path is configured with https://github.com/microsoft/typespec#installing-vs-code-extension.", + ].join("\n"), + [], + { showOutput: false, showPopup: true }, + ); + logger.error("Error detail", [e]); + } else { + logger.error("Unexpected error when starting TypeSpec server", [e], { + showOutput: false, + showPopup: true, + }); + } + } + } + + async dispose(): Promise { + if (this.client) { + await this.client.dispose(); + } + } + + static async create( + context: ExtensionContext, + outputChannel: LogOutputChannel, + ): Promise { + const exe = await resolveTypeSpecServer(context); + logger.debug("TypeSpec server resolved as ", [exe]); + const watchers = [ + workspace.createFileSystemWatcher("**/*.cadl"), + workspace.createFileSystemWatcher("**/cadl-project.yaml"), + workspace.createFileSystemWatcher("**/*.tsp"), + workspace.createFileSystemWatcher("**/tspconfig.yaml"), + // please be aware that the vscode watch with '**' will honer the files.watcherExclude settings + // so we won't get notification for those package.json under node_modules + // if our customers exclude the node_modules folder in files.watcherExclude settings. + workspace.createFileSystemWatcher("**/package.json"), + ]; + for (const folder of workspace.workspaceFolders ?? []) { + for (const p of listParentFolder(folder.uri.fsPath, false /*includeSelf*/)) { + watchers.push(workspace.createFileSystemWatcher(new RelativePattern(p, "package.json"))); + } + } + watchers.forEach((w) => context.subscriptions.push(w)); + + const options: LanguageClientOptions = { + synchronize: { + // Synchronize the setting section 'typespec' to the server + configurationSection: "typespec", + fileEvents: watchers, + }, + documentSelector: [ + { scheme: "file", language: "typespec" }, + { scheme: "untitled", language: "typespec" }, + { scheme: "file", language: "yaml", pattern: "**/tspconfig.yaml" }, + ], + outputChannel, + }; + + const name = "TypeSpec"; + const id = "typespec"; + const lc = new LanguageClient(id, name, { run: exe, debug: exe }, options); + return new TspLanguageClient(lc, exe); + } +} diff --git a/packages/typespec-vscode/src/utils.ts b/packages/typespec-vscode/src/utils.ts index 59676946a0..1bbed0a3ff 100644 --- a/packages/typespec-vscode/src/utils.ts +++ b/packages/typespec-vscode/src/utils.ts @@ -1,11 +1,39 @@ -import { dirname } from "path"; +import type { ModuleResolutionResult, ResolveModuleHost } from "@typespec/compiler"; +import { readFile, realpath, stat } from "fs/promises"; +import { dirname, normalize, resolve } from "path"; import { Executable } from "vscode-languageclient/node.js"; +import logger from "./log/logger.js"; /** normalize / and \\ to / */ export function normalizeSlash(str: string): string { return str.replaceAll(/\\/g, "/"); } +export function normalizePath(path: string): string { + const normalized = normalize(path); + const resolved = resolve(normalized); + const result = normalizeSlash(resolved); + return result; +} + +export async function isFile(path: string) { + try { + const stats = await stat(path); + return stats.isFile(); + } catch { + return false; + } +} + +export async function isDirectory(path: string) { + try { + const stats = await stat(path); + return stats.isDirectory(); + } catch { + return false; + } +} + export function isWhitespaceStringOrUndefined(str: string | undefined): boolean { return str === undefined || str.trim() === ""; } @@ -49,3 +77,28 @@ export function useShellInExec(exe: Executable, win32Only: boolean = true): Exec } return exe; } + +export async function loadModule( + baseDir: string, + packageName: string, +): Promise { + const { resolveModule } = await import("@typespec/compiler/module-resolver"); + + const host: ResolveModuleHost = { + realpath, + readFile: (path: string) => readFile(path, "utf-8"), + stat, + }; + try { + logger.debug(`Try to resolve module ${packageName} from local, baseDir: ${baseDir}`); + const module = await logger.profile(`Resolving module ${packageName}`, async () => { + return await resolveModule(host, packageName, { + baseDir, + }); + }); + return module; + } catch (e) { + logger.debug(`Exception when resolving module for ${packageName} from ${baseDir}`, [e]); + return undefined; + } +} diff --git a/packages/typespec-vscode/src/vscode-variable-resolver.ts b/packages/typespec-vscode/src/vscode-variable-resolver.ts new file mode 100644 index 0000000000..2e38eb6202 --- /dev/null +++ b/packages/typespec-vscode/src/vscode-variable-resolver.ts @@ -0,0 +1,20 @@ +/** + * Resolve some of the VSCode variables. + * Simpler aLternative until https://github.com/microsoft/vscode/issues/46471 is supported. + */ +export class VSCodeVariableResolver { + static readonly VARIABLE_REGEXP = /\$\{([^{}]+?)\}/g; + + public constructor(private variables: Record) {} + + public resolve(value: string): string { + const replaced = value.replace( + VSCodeVariableResolver.VARIABLE_REGEXP, + (match: string, variable: string) => { + return this.variables[variable] ?? match; + }, + ); + + return replaced; + } +} diff --git a/packages/typespec-vscode/src/web/extension.ts b/packages/typespec-vscode/src/web/extension.ts index 28ad61492f..e066bb7c8e 100644 --- a/packages/typespec-vscode/src/web/extension.ts +++ b/packages/typespec-vscode/src/web/extension.ts @@ -1,13 +1,14 @@ import type { ExtensionContext } from "vscode"; -import logger from "../extension-logger.js"; -import { TypeSpecLogOutputChannel } from "../typespec-log-output-channel.js"; +import { ExtensionLogListener } from "../log/extension-log-listener.js"; +import logger from "../log/logger.js"; +import { TypeSpecLogOutputChannel } from "../log/typespec-log-output-channel.js"; /** * Workaround: LogOutputChannel doesn't work well with LSP RemoteConsole, so having a customized LogOutputChannel to make them work together properly * More detail can be found at https://github.com/microsoft/vscode-discussions/discussions/1149 */ const outputChannel = new TypeSpecLogOutputChannel("TypeSpec"); -logger.outputChannel = outputChannel; +logger.registerLogListener("extension log", new ExtensionLogListener(outputChannel)); export async function activate(context: ExtensionContext) { logger.info("Activated TypeSpec Web Extension."); diff --git a/packages/typespec-vscode/test/unit/extension.test.ts b/packages/typespec-vscode/test/unit/extension.test.ts new file mode 100644 index 0000000000..b82a72d41d --- /dev/null +++ b/packages/typespec-vscode/test/unit/extension.test.ts @@ -0,0 +1,16 @@ +import { assert, beforeAll, describe, it } from "vitest"; +import { ConsoleLogLogger } from "../../src/log/console-log-listener.js"; +import logger from "../../src/log/logger.js"; + +beforeAll(() => { + // we don't have vscode in test env. Hook console log listener + logger.registerLogListener("test", new ConsoleLogLogger()); +}); + +describe("Hello world test", () => { + it("should pass", () => { + assert(true, "test sample"); + }); + + // Add more unit test when needed +}); diff --git a/packages/typespec-vscode/test/data/basic.tsp b/packages/typespec-vscode/test/web/data/basic.tsp similarity index 100% rename from packages/typespec-vscode/test/data/basic.tsp rename to packages/typespec-vscode/test/web/data/basic.tsp diff --git a/packages/typespec-vscode/test/suite.ts b/packages/typespec-vscode/test/web/suite.ts similarity index 100% rename from packages/typespec-vscode/test/suite.ts rename to packages/typespec-vscode/test/web/suite.ts diff --git a/packages/typespec-vscode/test/web.test.ts b/packages/typespec-vscode/test/web/web.test.ts similarity index 100% rename from packages/typespec-vscode/test/web.test.ts rename to packages/typespec-vscode/test/web/web.test.ts diff --git a/packages/typespec-vscode/vitest.config.ts b/packages/typespec-vscode/vitest.config.ts new file mode 100644 index 0000000000..81b26da7b3 --- /dev/null +++ b/packages/typespec-vscode/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.workspace.js"; + +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + test: { + include: ["test/unit/**/*.test.ts"], + }, + }), +);