diff --git a/.chronus/changes/generateSDK-2024-11-10-10-45-35.md b/.chronus/changes/generateSDK-2024-11-10-10-45-35.md new file mode 100644 index 0000000000..6c70a9ff96 --- /dev/null +++ b/.chronus/changes/generateSDK-2024-11-10-10-45-35.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - typespec-vscode +--- + +integrate client SDK generation \ No newline at end of file diff --git a/packages/typespec-vscode/icons/client.svg b/packages/typespec-vscode/icons/client.svg new file mode 100644 index 0000000000..b493a458d1 --- /dev/null +++ b/packages/typespec-vscode/icons/client.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/typespec-vscode/icons/dotnet.svg b/packages/typespec-vscode/icons/dotnet.svg new file mode 100644 index 0000000000..9c614b11d5 --- /dev/null +++ b/packages/typespec-vscode/icons/dotnet.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/typespec-vscode/icons/java.svg b/packages/typespec-vscode/icons/java.svg new file mode 100644 index 0000000000..649dcb26c8 --- /dev/null +++ b/packages/typespec-vscode/icons/java.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/typespec-vscode/icons/javascript.svg b/packages/typespec-vscode/icons/javascript.svg new file mode 100644 index 0000000000..a400bccfd0 --- /dev/null +++ b/packages/typespec-vscode/icons/javascript.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/typespec-vscode/icons/openapi3.svg b/packages/typespec-vscode/icons/openapi3.svg new file mode 100644 index 0000000000..22b781fe93 --- /dev/null +++ b/packages/typespec-vscode/icons/openapi3.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/typespec-vscode/icons/python.svg b/packages/typespec-vscode/icons/python.svg new file mode 100644 index 0000000000..3490261bed --- /dev/null +++ b/packages/typespec-vscode/icons/python.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/typespec-vscode/icons/schema.svg b/packages/typespec-vscode/icons/schema.svg new file mode 100644 index 0000000000..a871efb4bf --- /dev/null +++ b/packages/typespec-vscode/icons/schema.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/typespec-vscode/icons/server.svg b/packages/typespec-vscode/icons/server.svg new file mode 100644 index 0000000000..828b762e17 --- /dev/null +++ b/packages/typespec-vscode/icons/server.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/typespec-vscode/package.json b/packages/typespec-vscode/package.json index 52fc4e906b..096a1235ed 100644 --- a/packages/typespec-vscode/package.json +++ b/packages/typespec-vscode/package.json @@ -108,6 +108,79 @@ ], "default": "off", "description": "Define whether/how the TypeSpec language server should send traces to client. For the traces to show properly in vscode Output, make sure 'Log Level' is also set to 'Trace' so that they won't be filtered at client side, which can be set through 'Developer: Set Log Level...' command." + }, + "typespec.generateCode.emitters": { + "scope": "window", + "type": "array", + "items": { + "type": "object", + "properties": { + "language": { + "type": "string", + "enum": [ + "DotNet", + "Java", + "JavaScript", + "Python", + "Go", + "OpenAPI3", + "ProtoBuf", + "JsonSchema" + ], + "description": "Define the language the emitter will emit." + }, + "package": { + "type": "string", + "description": "Define the emitter package.\n\nExample (with version): @typespec/http-client-csharp@1.0.0\n\nExample (without version): @typespec/http-client-csharp" + }, + "kind": { + "type": "string", + "enum": [ + "client", + "server", + "schema" + ], + "description": "Define the emitter kind." + } + } + }, + "default": [ + { + "language": "DotNet", + "package": "@typespec/http-client-csharp", + "kind": "client" + }, + { + "language": "Java", + "package": "@typespec/http-client-java", + "kind": "client" + }, + { + "language": "JavaScript", + "package": "@azure-tools/typespec-ts", + "kind": "client" + }, + { + "language": "Python", + "package": "@typespec/http-client-python", + "kind": "client" + }, + { + "language": "DotNet", + "package": "@typespec/http-server-csharp", + "kind": "server" + }, + { + "language": "JavaScript", + "package": "@typespec/http-server-javascript", + "kind": "server" + }, + { + "language": "OpenAPI3", + "package": "@typespec/openapi3", + "kind": "schema" + } + ] } } } @@ -141,6 +214,11 @@ "title": "Show Output Channel", "category": "TypeSpec" }, + { + "command": "typespec.generateCode", + "title": "Generate from TypeSpec", + "category": "TypeSpec" + }, { "command": "typespec.createProject", "title": "Create TypeSpec Project", @@ -152,6 +230,22 @@ "category": "TypeSpec" } ], + "menus": { + "explorer/context": [ + { + "command": "typespec.generateCode", + "when": "explorerResourceIsFolder || resourceLangId == typespec", + "group": "code_generation" + } + ], + "editor/context": [ + { + "command": "typespec.generateCode", + "when": "resourceLangId == typespec", + "group": "code_generation" + } + ] + }, "semanticTokenScopes": [ { "scopes": { diff --git a/packages/typespec-vscode/src/const.ts b/packages/typespec-vscode/src/const.ts new file mode 100644 index 0000000000..e7b898f945 --- /dev/null +++ b/packages/typespec-vscode/src/const.ts @@ -0,0 +1 @@ +export const StartFileName = "main.tsp"; diff --git a/packages/typespec-vscode/src/extension.ts b/packages/typespec-vscode/src/extension.ts index 6495b6bbd0..efbd44898e 100644 --- a/packages/typespec-vscode/src/extension.ts +++ b/packages/typespec-vscode/src/extension.ts @@ -13,6 +13,7 @@ import { SettingName, } from "./types.js"; import { createTypeSpecProject } from "./vscode-cmd/create-tsp-project.js"; +import { emitCode } from "./vscode-cmd/emit-code/emit-code.js"; import { installCompilerGlobally } from "./vscode-cmd/install-tsp-compiler.js"; let client: TspLanguageClient | undefined; @@ -44,6 +45,20 @@ export async function activate(context: ExtensionContext) { }), ); + /* emit command. */ + context.subscriptions.push( + commands.registerCommand(CommandName.GenerateCode, async (uri: vscode.Uri) => { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: "Generate from TypeSpec...", + cancellable: false, + }, + async () => await emitCode(context, uri), + ); + }), + ); + context.subscriptions.push( commands.registerCommand( CommandName.RestartServer, diff --git a/packages/typespec-vscode/src/npm-utils.ts b/packages/typespec-vscode/src/npm-utils.ts new file mode 100644 index 0000000000..33550721db --- /dev/null +++ b/packages/typespec-vscode/src/npm-utils.ts @@ -0,0 +1,146 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import semver from "semver"; +import logger from "./log/logger.js"; +import { ExecOutput, loadModule, spawnExecution, spawnExecutionEvents } from "./utils.js"; + +export enum InstallationAction { + Install = "Install", + Upgrade = "Upgrade", + Skip = "Skip", + Cancel = "Cancel", +} + +export enum npmDependencyType { + dependencies = "dependencies", + peerDependencies = "peerDependencies", + devDependencies = "devDependencies", +} + +export interface NpmPackageInfo { + name: string; + version?: string; + resolved?: string; + overridden?: string; + dependencies?: Record; +} + +export class NpmUtil { + private cwd: string; + + constructor(cwd: string) { + this.cwd = cwd; + } + + public async npmInstallPackages( + packages: string[] = [], + options: any = {}, + on?: spawnExecutionEvents, + ): Promise { + return spawnExecution("npm", ["install", ...packages], this.cwd, on); + } + + /* identify the action to take for a package. install or skip or cancel or upgrade */ + public async calculateNpmPackageInstallAction( + packageName: string, + version?: string, + ): Promise<{ action: InstallationAction; version?: string }> { + const { installed: isPackageInstalled, version: installedVersion } = + await this.isPackageInstalled(packageName); + if (isPackageInstalled) { + if (version && installedVersion !== version) { + if (semver.gt(version, installedVersion!)) { + return { action: InstallationAction.Upgrade, version: version }; + } else { + logger.info( + "The version to intall is less than the installed version. Skip installation.", + ); + return { action: InstallationAction.Skip, version: installedVersion }; + } + } + return { action: InstallationAction.Skip, version: installedVersion }; + } else { + return { action: InstallationAction.Install, version: version }; + } + } + + /* identify the dependency packages need to be upgraded */ + public async calculateNpmPackageDependencyToUpgrade( + packageName: string, + version?: string, + dependencyType: npmDependencyType = npmDependencyType.dependencies, + on?: spawnExecutionEvents, + ): Promise { + const dependenciesToInstall: string[] = []; + let packageFullName = packageName; + if (version) { + packageFullName = `${packageName}@${version}`; + } + + /* get dependencies. */ + try { + const dependenciesResult = await spawnExecution( + "npm", + ["view", packageFullName, dependencyType, "--json"], + this.cwd, + on, + ); + + if (dependenciesResult.exitCode === 0) { + const json = JSON.parse(dependenciesResult.stdout); + for (const [key, value] of Object.entries(json)) { + const { installed, version: installedVersion } = await this.isPackageInstalled(key); + if (installed && installedVersion) { + if (!this.isValidVersion(installedVersion, value as string)) { + dependenciesToInstall.push(`${key}@latest`); + } + } + } + } else { + logger.error("Error getting dependencies.", [dependenciesResult.stderr]); + } + } catch (err) { + if (on && on.onError) { + on.onError(err, "", ""); + } + logger.error("Error getting dependencies.", [err]); + } + + return dependenciesToInstall; + } + + private isValidVersion(version: string, range: string): boolean { + return semver.satisfies(version, range); + } + + private async isPackageInstalled( + packageName: string, + ): Promise<{ installed: boolean; version: string | undefined }> { + const packageInfo = await this.loadNpmPackage(packageName); + if (packageInfo) return { installed: true, version: packageInfo.version }; + return { installed: false, version: undefined }; + } + + private async loadNpmPackage(packageName: string): Promise { + const executable = await loadModule(this.cwd, packageName); + if (executable) { + const packageJsonPath = path.resolve(executable.path, "package.json"); + + /* get the package version. */ + let version; + try { + const data = await readFile(packageJsonPath, { encoding: "utf-8" }); + const packageJson = JSON.parse(data); + version = packageJson.version; + } catch (error) { + logger.error("Error reading package.json.", [error]); + } + return { + name: packageName, + version: version, + }; + } + + return undefined; + } +} diff --git a/packages/typespec-vscode/src/task-provider.ts b/packages/typespec-vscode/src/task-provider.ts index 39d8da558e..c0f0c16ee5 100644 --- a/packages/typespec-vscode/src/task-provider.ts +++ b/packages/typespec-vscode/src/task-provider.ts @@ -1,6 +1,7 @@ import { resolve } from "path"; import vscode, { workspace } from "vscode"; import { Executable } from "vscode-languageclient/node.js"; +import { StartFileName } from "./const.js"; import logger from "./log/logger.js"; import { normalizeSlashes } from "./path-utils.js"; import { resolveTypeSpecCli } from "./tsp-executable-resolver.js"; @@ -11,13 +12,13 @@ export function createTaskProvider() { provideTasks: async () => { logger.info("Providing tsp tasks"); const targetPathes = await vscode.workspace - .findFiles("**/main.tsp", "**/node_modules/**") + .findFiles(`**/${StartFileName}`, "**/node_modules/**") .then((uris) => uris .filter((uri) => uri.scheme === "file" && !uri.fsPath.includes("node_modules")) .map((uri) => normalizeSlashes(uri.fsPath)), ); - logger.info(`Found ${targetPathes.length} main.tsp files`); + logger.info(`Found ${targetPathes.length} ${StartFileName} files`); const tasks: vscode.Task[] = []; for (const targetPath of targetPathes) { tasks.push(...(await createBuiltInTasks(targetPath))); diff --git a/packages/typespec-vscode/src/types.ts b/packages/typespec-vscode/src/types.ts index c21dba7e6b..899f88df1a 100644 --- a/packages/typespec-vscode/src/types.ts +++ b/packages/typespec-vscode/src/types.ts @@ -1,6 +1,7 @@ export const enum SettingName { TspServerPath = "typespec.tsp-server.path", InitTemplatesUrls = "typespec.initTemplatesUrls", + GenerateCodeEmitters = "typespec.generateCode.emitters", } export const enum CommandName { @@ -9,6 +10,7 @@ export const enum CommandName { InstallGlobalCompilerCli = "typespec.installGlobalCompilerCli", CreateProject = "typespec.createProject", OpenUrl = "typespec.openUrl", + GenerateCode = "typespec.generateCode", } export interface InstallGlobalCliCommandArgs { diff --git a/packages/typespec-vscode/src/typespec-utils.ts b/packages/typespec-vscode/src/typespec-utils.ts new file mode 100644 index 0000000000..ad23718e6c --- /dev/null +++ b/packages/typespec-vscode/src/typespec-utils.ts @@ -0,0 +1,40 @@ +import path from "path"; +import vscode from "vscode"; +import { StartFileName } from "./const.js"; +import logger from "./log/logger.js"; +import { getDirectoryPath, normalizeSlashes } from "./path-utils.js"; +import { isFile } from "./utils.js"; + +export const logStdoutLineByLineCallBack = (str: string) => { + str + .trim() + .split("\n") + .forEach((line) => logger.info(line)); +}; +export const logStderrorLineByLineCallBack = (str: string) => { + str + .trim() + .split("\n") + .forEach((line) => logger.error(line)); +}; + +export async function getEntrypointTspFile(tspPath: string): Promise { + const isFilePath = await isFile(tspPath); + const baseDir = isFilePath ? getDirectoryPath(tspPath) : tspPath; + const mainTspFile = path.resolve(baseDir, StartFileName); + if (await isFile(mainTspFile)) { + return mainTspFile; + } + + return undefined; +} + +export async function TraverseMainTspFileInWorkspace() { + return vscode.workspace + .findFiles(`**/${StartFileName}`, "**/node_modules/**") + .then((uris) => + uris + .filter((uri) => uri.scheme === "file" && !uri.fsPath.includes("node_modules")) + .map((uri) => normalizeSlashes(uri.fsPath)), + ); +} diff --git a/packages/typespec-vscode/src/vscode-cmd/emit-code/emit-code.ts b/packages/typespec-vscode/src/vscode-cmd/emit-code/emit-code.ts new file mode 100644 index 0000000000..6ac8bd2357 --- /dev/null +++ b/packages/typespec-vscode/src/vscode-cmd/emit-code/emit-code.ts @@ -0,0 +1,325 @@ +import path from "path"; +import vscode, { Uri } from "vscode"; +import { Executable } from "vscode-languageclient/node.js"; +import { StartFileName } from "../../const.js"; +import logger from "../../log/logger.js"; +import { InstallationAction, npmDependencyType, NpmUtil } from "../../npm-utils.js"; +import { getDirectoryPath } from "../../path-utils.js"; +import { resolveTypeSpecCli } from "../../tsp-executable-resolver.js"; +import { + getEntrypointTspFile, + logStderrorLineByLineCallBack, + logStdoutLineByLineCallBack, + TraverseMainTspFileInWorkspace, +} from "../../typespec-utils.js"; +import { ExecOutput, isFile, spawnExecution } from "../../utils.js"; +import { EmitQuickPickItem } from "./emit-quick-pick-item.js"; +import { + Emitter, + EmitterKind, + getRegisterEmitters, + getRegisterEmitterTypes, + PreDefinedEmitterPickItems, +} from "./emitter.js"; + +async function doEmit(context: vscode.ExtensionContext, mainTspFile: string, kind: EmitterKind) { + if (!mainTspFile || !(await isFile(mainTspFile))) { + logger.error( + "Invalid typespec project. There is no main tsp file in the project. Generating Cancelled.", + [], + { showOutput: false, showPopup: true }, + ); + return; + } + + const baseDir = getDirectoryPath(mainTspFile); + + const toQuickPickItem = (e: Emitter): EmitQuickPickItem => { + return { + language: e.language, + package: e.package, + emitterKind: e.kind, + label: e.language, + detail: `Generate ${e.language} ${e.kind} code from ${e.package}`, + picked: false, + fromConfig: false, + iconPath: Uri.file(context.asAbsolutePath(`./icons/${e.language.toLowerCase()}.svg`)), + }; + }; + + const registerEmitters = getRegisterEmitters(kind); + const all = [...registerEmitters].map((e) => toQuickPickItem(e)); + + const selectedEmitter = await vscode.window.showQuickPick(all, { + title: "Select a Language", + canPickMany: false, + placeHolder: "Pick a Language", + ignoreFocusOut: true, + }); + + if (!selectedEmitter) { + logger.info("No emitter selected. Generating Cancelled."); + return; + } + + const npmUtil = new NpmUtil(baseDir); + const packagesToInstall: string[] = []; + + /* install emitter package. */ + logger.info(`select ${selectedEmitter.package}`); + const { action, version } = await npmUtil.calculateNpmPackageInstallAction( + selectedEmitter.package, + selectedEmitter.version, + ); + + if (action === InstallationAction.Upgrade) { + logger.info(`Upgrading ${selectedEmitter.package} to version ${version}`); + const options = { + ok: `OK (install ${selectedEmitter.package}@${version} by 'npm install'`, + recheck: `Check again (install ${selectedEmitter.package} manually)`, + ignore: `Ignore (don't upgrade emitter ${selectedEmitter.package})`, + }; + const selected = await vscode.window.showQuickPick(Object.values(options), { + canPickMany: false, + ignoreFocusOut: true, + placeHolder: `Package '${selectedEmitter.package}' needs to be upgraded for generating`, + title: `TypeSpec Generating...`, + }); + if (selected === options.ok) { + packagesToInstall.push(`${selectedEmitter.package}@${version}`); + } else if (selected === options.ignore) { + logger.info(`Ignore upgrading emitter ${selectedEmitter.package} for generating`); + } else { + logger.info( + `Need to manually install the package ${selectedEmitter.package}@${version}. Generating Cancelled.`, + [], + { + showOutput: false, + showPopup: true, + }, + ); + return; + } + } else if (action === InstallationAction.Install) { + let packageFullName = selectedEmitter.package; + if (version) { + packageFullName = `${selectedEmitter.package}@${version}`; + } + logger.info(`To install ${packageFullName}`); + /* verify dependency packages. */ + const dependenciesToInstall = await npmUtil.calculateNpmPackageDependencyToUpgrade( + selectedEmitter.package, + version, + npmDependencyType.peerDependencies, + ); + logger.info(`${dependenciesToInstall}`); + if (dependenciesToInstall.length > 0) { + vscode.window.showInformationMessage( + `Need to manually upgrade following dependency packages: ${dependenciesToInstall.join("\\n")}. \nGenerating Cancelled`, + "OK", + ); + return; + } + packagesToInstall.push(`${packageFullName}`); + } + + /* npm install packages. */ + if (packagesToInstall.length > 0) { + logger.info(`Installing ${packagesToInstall.join("\n\n")} under ${baseDir}`); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Installing packages...", + cancellable: false, + }, + async () => { + try { + const npmInstallResult = await npmUtil.npmInstallPackages(packagesToInstall, undefined, { + onStdioOut: logStdoutLineByLineCallBack, + onStdioError: logStderrorLineByLineCallBack, + }); + if (npmInstallResult.exitCode !== 0) { + logger.error( + `Error occurred when installing packages.`, + [`${npmInstallResult.stderr}`], + { + showOutput: true, + showPopup: true, + }, + ); + return; + } + } catch (err) { + logger.error(`Exception occurred when installing packages.`, [err], { + showOutput: true, + showPopup: true, + }); + return; + } + }, + ); + } + + /* emit */ + const cli = await resolveTypeSpecCli(baseDir); + if (!cli) { + logger.error( + "Cannot find TypeSpec CLI. Please install @typespec/compiler. Generating Cancelled.", + [], + { + showOutput: true, + showPopup: true, + }, + ); + return; + } + const outputDir = path.join(baseDir, selectedEmitter.emitterKind, selectedEmitter.language); + + const options: Record = {}; + options["emitter-output-dir"] = outputDir; + logger.info( + `Start to generate ${selectedEmitter.language} ${selectedEmitter.emitterKind} code under ${outputDir}...`, + ); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Generating ${selectedEmitter.emitterKind} code for ${selectedEmitter.language}...`, + cancellable: false, + }, + async () => { + try { + const compileResult = await compile(cli, mainTspFile, selectedEmitter.package, options); + if (compileResult.exitCode !== 0) { + logger.error( + `Generating ${selectedEmitter.emitterKind} code for ${selectedEmitter.language}...Failed`, + [], + { + showOutput: true, + showPopup: true, + }, + ); + } else { + logger.info( + `Generating ${selectedEmitter.emitterKind} code for ${selectedEmitter.language}...Succeeded`, + [], + { + showOutput: true, + showPopup: true, + }, + ); + } + } catch (err) { + logger.error( + `Exception occurred when generating ${selectedEmitter.emitterKind} code for ${selectedEmitter.language}.`, + [err], + { + showOutput: true, + showPopup: true, + }, + ); + } + }, + ); +} + +export async function emitCode(context: vscode.ExtensionContext, uri: vscode.Uri) { + let tspProjectFile: string = ""; + if (!uri) { + const targetPathes = await TraverseMainTspFileInWorkspace(); + logger.info(`Found ${targetPathes.length} ${StartFileName} files`); + if (targetPathes.length === 0) { + logger.info(`No entrypoint file (${StartFileName}) found. Generating Cancelled.`, [], { + showOutput: true, + showPopup: true, + }); + return; + } else if (targetPathes.length === 1) { + tspProjectFile = targetPathes[0]; + } else { + const toProjectPickItem = (filePath: string): any => { + return { + label: filePath, + path: filePath, + iconPath: { + light: Uri.file(context.asAbsolutePath(`./icons/tsp-file.light.svg`)), + dark: Uri.file(context.asAbsolutePath(`./icons/tsp-file.dark.svg`)), + }, + }; + }; + const typespecProjectQuickPickItems: any[] = targetPathes.map((filePath) => + toProjectPickItem(filePath), + ); + const selectedProjectFile = await vscode.window.showQuickPick(typespecProjectQuickPickItems, { + title: "Select a TypeSpec Project", + canPickMany: false, + placeHolder: "Pick a project", + ignoreFocusOut: true, + }); + if (!selectedProjectFile) { + logger.info("No project selected. Generating Cancelled.", [], { + showOutput: true, + showPopup: true, + }); + return; + } + tspProjectFile = selectedProjectFile.path; + } + } else { + const tspStartFile = await getEntrypointTspFile(uri.fsPath); + if (!tspStartFile) { + logger.info(`No entrypoint file (${StartFileName}). Invalid typespec project.`, [], { + showOutput: true, + showPopup: true, + }); + return; + } + tspProjectFile = tspStartFile; + } + + logger.info(`Generate from entrypoint file: ${tspProjectFile}`); + + const emitterKinds = getRegisterEmitterTypes(); + const toEmitterTypeQuickPickItem = (kind: EmitterKind): any => { + return { + label: PreDefinedEmitterPickItems[kind]?.label ?? kind, + detail: PreDefinedEmitterPickItems[kind]?.detail ?? `Generate ${kind} code from TypeSpec`, + emitterKind: kind, + iconPath: Uri.file(context.asAbsolutePath(`./icons/${kind.toLowerCase()}.svg`)), + }; + }; + const codesToEmit = emitterKinds.map((kind) => toEmitterTypeQuickPickItem(kind)); + const codeType = await vscode.window.showQuickPick(codesToEmit, { + title: "Select an Emitter Type", + canPickMany: false, + placeHolder: "Select an emitter type", + ignoreFocusOut: true, + }); + if (!codeType) { + logger.info("No emitter Type selected. Generating Cancelled."); + return; + } + await doEmit(context, tspProjectFile, codeType.emitterKind); +} + +async function compile( + cli: Executable, + startFile: string, + emitter: string, + options: Record, +): Promise { + const args: string[] = cli.args ?? []; + args.push("compile"); + args.push(startFile); + if (emitter) { + args.push("--emit", emitter); + } + + for (const [key, value] of Object.entries(options)) { + args.push("--option", `${emitter}.${key}=${value}`); + } + + return await spawnExecution(cli.command, args, getDirectoryPath(startFile), { + onStdioOut: logStdoutLineByLineCallBack, + onStdioError: logStderrorLineByLineCallBack, + }); +} diff --git a/packages/typespec-vscode/src/vscode-cmd/emit-code/emit-quick-pick-item.ts b/packages/typespec-vscode/src/vscode-cmd/emit-code/emit-quick-pick-item.ts new file mode 100644 index 0000000000..9be498b995 --- /dev/null +++ b/packages/typespec-vscode/src/vscode-cmd/emit-code/emit-quick-pick-item.ts @@ -0,0 +1,11 @@ +import vscode from "vscode"; +import { EmitterKind } from "./emitter.js"; + +export interface EmitQuickPickItem extends vscode.QuickPickItem { + language: string; + package: string; + version?: string; + fromConfig: boolean; + outputDir?: string; + emitterKind: EmitterKind; +} diff --git a/packages/typespec-vscode/src/vscode-cmd/emit-code/emitter.ts b/packages/typespec-vscode/src/vscode-cmd/emit-code/emitter.ts new file mode 100644 index 0000000000..6ef43e0c87 --- /dev/null +++ b/packages/typespec-vscode/src/vscode-cmd/emit-code/emitter.ts @@ -0,0 +1,71 @@ +import vscode from "vscode"; +import logger from "../../log/logger.js"; +import { SettingName } from "../../types.js"; + +export enum EmitterKind { + Schema = "schema", + Client = "client", + Server = "server", +} + +export interface Emitter { + language: string; + package: string; + version?: string; + kind: EmitterKind; +} + +export const PreDefinedEmitterPickItems: Record = { + schema: { + label: "OpenAPI", + detail: "Generating OpenAPI from TypeSpec", + }, + client: { + label: "Client Code", + detail: "Generating Client Code from TypeSpec.", + }, + server: { + label: " Server Stub", + detail: "Generating Server Stub from TypeSpec", + }, +}; + +function getEmitter(kind: EmitterKind, emitter: Emitter): Emitter | undefined { + let packageFullName: string = emitter.package; + if (!packageFullName) { + logger.error("Emitter package name is required."); + return undefined; + } + packageFullName = packageFullName.trim(); + const index = packageFullName.lastIndexOf("@"); + let version = undefined; + let packageName = packageFullName; + if (index !== -1 && index !== 0) { + version = packageFullName.substring(index + 1); + packageName = packageFullName.substring(0, index); + } + + return { + language: emitter.language, + package: packageName, + version: version, + kind: kind, + }; +} + +export function getRegisterEmitters(kind: EmitterKind): ReadonlyArray { + const extensionConfig = vscode.workspace.getConfiguration(); + const emitters: ReadonlyArray = + extensionConfig.get(SettingName.GenerateCodeEmitters) ?? []; + return emitters + .filter((emitter) => emitter.kind === kind) + .map((emitter) => getEmitter(kind, emitter)) + .filter((emitter) => emitter !== undefined) as Emitter[]; +} + +export function getRegisterEmitterTypes(): ReadonlyArray { + const extensionConfig = vscode.workspace.getConfiguration(); + const emitters: ReadonlyArray = + extensionConfig.get(SettingName.GenerateCodeEmitters) ?? []; + return Array.from(new Set(emitters.map((emitter) => emitter.kind))); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ec21bf0d3..4d84583b8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 22.7.9 '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) c8: specifier: ^10.1.2 version: 10.1.2 @@ -67,7 +67,7 @@ importers: version: 56.0.1(eslint@9.15.0(jiti@1.21.6)) eslint-plugin-vitest: specifier: ^0.5.4 - version: 0.5.4(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 0.5.4(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5) micromatch: specifier: ^4.0.8 version: 4.0.8 @@ -151,7 +151,7 @@ importers: version: link:../compiler '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -200,7 +200,7 @@ importers: version: 7.5.8 '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -258,7 +258,7 @@ importers: version: 17.0.33 '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -346,7 +346,7 @@ importers: version: link:../internal-build-utils '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -398,7 +398,7 @@ importers: version: 8.15.0 '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -434,7 +434,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -504,7 +504,7 @@ importers: version: 4.3.3(vite@5.4.11(@types/node@22.7.9)) '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -549,7 +549,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -598,7 +598,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -712,7 +712,7 @@ importers: version: 17.0.33 '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -755,7 +755,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -788,7 +788,7 @@ importers: version: link:../compiler '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -816,7 +816,7 @@ importers: version: 22.7.9 '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -858,7 +858,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -919,7 +919,7 @@ importers: version: link:../xml '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1161,7 +1161,7 @@ importers: version: 4.3.3(vite@5.4.11(@types/node@22.7.9)) '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1237,7 +1237,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1298,7 +1298,7 @@ importers: version: 4.3.3(vite@5.4.11(@types/node@22.7.9)) '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1343,7 +1343,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1413,7 +1413,7 @@ importers: version: link:../internal-build-utils '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1507,7 +1507,7 @@ importers: version: 17.0.33 '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1729,7 +1729,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1762,7 +1762,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1830,7 +1830,7 @@ importers: version: link:../prettier-plugin-typespec '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1896,7 +1896,7 @@ importers: version: link:../internal-build-utils '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1947,7 +1947,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -1980,7 +1980,7 @@ importers: version: link:../tspd '@vitest/coverage-v8': specifier: ^2.1.5 - version: 2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)) + version: 2.1.5(vitest@2.1.5) '@vitest/ui': specifier: ^2.1.2 version: 2.1.5(vitest@2.1.5) @@ -16377,7 +16377,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.1.5(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0))': + '@vitest/coverage-v8@2.1.5(vitest@2.1.5)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -18556,7 +18556,7 @@ snapshots: semver: 7.6.3 strip-indent: 3.0.0 - eslint-plugin-vitest@0.5.4(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5(@types/node@22.7.9)(@vitest/ui@2.1.5)(happy-dom@15.11.6)(jsdom@19.0.0)): + eslint-plugin-vitest@0.5.4(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5): dependencies: '@typescript-eslint/utils': 7.18.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3) eslint: 9.15.0(jiti@1.21.6)