diff --git a/.chronus/changes/vs-vulnerabilities-2024-11-12-13-19-40.md b/.chronus/changes/vs-vulnerabilities-2024-11-12-13-19-40.md new file mode 100644 index 0000000000..57a864d692 --- /dev/null +++ b/.chronus/changes/vs-vulnerabilities-2024-11-12-13-19-40.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - typespec-vs +--- + +Upgrade nuget packages to avoid transitive vulnerabilities \ No newline at end of file diff --git a/.chronus/changes/vscode-scaffolding-2024-11-7-12-44-28.md b/.chronus/changes/vscode-scaffolding-2024-11-7-12-44-28.md new file mode 100644 index 0000000000..30710b6ee2 --- /dev/null +++ b/.chronus/changes/vscode-scaffolding-2024-11-7-12-44-28.md @@ -0,0 +1,20 @@ +--- +changeKind: feature +packages: + - typespec-vscode +--- + +Support "Create TypeSpec Project" in vscode command and EXPLORER when no folder opened +Add Setting "typespec.initTemplatesUrls" where user can configure additional template to use to create TypeSpec project +example: +``` +{ + "typespec.initTemplatesUrls": [ + { + "name": "displayName", + "url": "https://urlToTheFileContainsTemplates" + }], +} +``` +Support "Install TypeSpec Compiler/CLI globally" in vscode command to install TypeSpec compiler globally easily + diff --git a/.chronus/changes/vscode-scaffolding-2024-11-7-12-46-5.md b/.chronus/changes/vscode-scaffolding-2024-11-7-12-46-5.md new file mode 100644 index 0000000000..3c00d60b5a --- /dev/null +++ b/.chronus/changes/vscode-scaffolding-2024-11-7-12-46-5.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add capacities in TypeSpec Language Server to support "Scaffolding new TypeSpec project" in IDE \ No newline at end of file diff --git a/packages/compiler/src/core/node-host.browser.ts b/packages/compiler/src/core/node-host.browser.ts index 2d4abccbaf..2f3b630531 100644 --- a/packages/compiler/src/core/node-host.browser.ts +++ b/packages/compiler/src/core/node-host.browser.ts @@ -1 +1,2 @@ export const NodeHost = undefined; +export const CompilerPackageRoot = undefined; diff --git a/packages/compiler/src/init/core-templates.ts b/packages/compiler/src/init/core-templates.ts index 4f1285c223..ad284f8a70 100644 --- a/packages/compiler/src/init/core-templates.ts +++ b/packages/compiler/src/init/core-templates.ts @@ -1,12 +1,22 @@ -import { readFile } from "fs/promises"; import { CompilerPackageRoot } from "../core/node-host.js"; import { resolvePath } from "../core/path-utils.js"; +import { CompilerHost } from "../index.js"; export const templatesDir = resolvePath(CompilerPackageRoot, "templates"); +export interface LoadedCoreTemplates { + readonly baseUri: string; + readonly templates: Record; +} -const content = JSON.parse(await readFile(resolvePath(templatesDir, "scaffolding.json"), "utf-8")); - -export const TypeSpecCoreTemplates = { - baseUri: templatesDir, - templates: content, -}; +let typeSpecCoreTemplates: LoadedCoreTemplates | undefined; +export async function getTypeSpecCoreTemplates(host: CompilerHost): Promise { + if (typeSpecCoreTemplates === undefined) { + const file = await host.readFile(resolvePath(templatesDir, "scaffolding.json")); + const content = JSON.parse(file.text); + typeSpecCoreTemplates = { + baseUri: templatesDir, + templates: content, + }; + } + return typeSpecCoreTemplates; +} diff --git a/packages/compiler/src/init/init-template-validate.ts b/packages/compiler/src/init/init-template-validate.ts new file mode 100644 index 0000000000..cecb28c4bd --- /dev/null +++ b/packages/compiler/src/init/init-template-validate.ts @@ -0,0 +1,20 @@ +import { createJSONSchemaValidator } from "../core/schema-validator.js"; +import { Diagnostic, NoTarget, SourceFile } from "../index.js"; +import { InitTemplateSchema } from "./init-template.js"; + +export type ValidationResult = { + valid: boolean; + diagnostics: readonly Diagnostic[]; +}; + +export function validateTemplateDefinitions( + template: unknown, + templateName: SourceFile | typeof NoTarget, + strictValidation: boolean, +): ValidationResult { + const validator = createJSONSchemaValidator(InitTemplateSchema, { + strict: strictValidation, + }); + const diagnostics = validator.validate(template, templateName); + return { valid: diagnostics.length === 0, diagnostics }; +} diff --git a/packages/compiler/src/init/init.ts b/packages/compiler/src/init/init.ts index 2b66938446..efa4b843ef 100644 --- a/packages/compiler/src/init/init.ts +++ b/packages/compiler/src/init/init.ts @@ -4,12 +4,12 @@ import prompts from "prompts"; import * as semver from "semver"; import { createDiagnostic } from "../core/messages.js"; import { getBaseFileName, getDirectoryPath } from "../core/path-utils.js"; -import { createJSONSchemaValidator } from "../core/schema-validator.js"; import { CompilerHost, Diagnostic, NoTarget, SourceFile } from "../core/types.js"; import { MANIFEST } from "../manifest.js"; import { readUrlOrPath } from "../utils/misc.js"; -import { TypeSpecCoreTemplates } from "./core-templates.js"; -import { InitTemplate, InitTemplateLibrarySpec, InitTemplateSchema } from "./init-template.js"; +import { getTypeSpecCoreTemplates } from "./core-templates.js"; +import { validateTemplateDefinitions, ValidationResult } from "./init-template-validate.js"; +import { InitTemplate, InitTemplateLibrarySpec } from "./init-template.js"; import { makeScaffoldingConfig, normalizeLibrary, scaffoldNewProject } from "./scaffold.js"; export interface InitTypeSpecProjectOptions { @@ -30,15 +30,16 @@ export async function initTypeSpecProject( // Download template configuration and prompt user to select a template // No validation is done until one has been selected + const typeSpecCoreTemplates = await getTypeSpecCoreTemplates(host); const result = options.templatesUrl === undefined - ? (TypeSpecCoreTemplates as LoadedTemplate) + ? (typeSpecCoreTemplates as LoadedTemplate) : await downloadTemplates(host, options.templatesUrl); const templateName = options.template ?? (await promptTemplateSelection(result.templates)); // Validate minimum compiler version for non built-in templates if ( - result !== TypeSpecCoreTemplates && + result !== typeSpecCoreTemplates && !(await validateTemplate(result.templates[templateName], result)) ) { return; @@ -193,11 +194,6 @@ async function promptTemplateSelection(templates: Record): Promise< return templateName; } -type ValidationResult = { - valid: boolean; - diagnostics: readonly Diagnostic[]; -}; - async function validateTemplate(template: any, loaded: LoadedTemplate): Promise { // After selection, validate the template definition const currentCompilerVersion = MANIFEST.version; @@ -278,18 +274,6 @@ export class InitTemplateError extends Error { } } -function validateTemplateDefinitions( - template: unknown, - templateName: SourceFile, - strictValidation: boolean, -): ValidationResult { - const validator = createJSONSchemaValidator(InitTemplateSchema, { - strict: strictValidation, - }); - const diagnostics = validator.validate(template, templateName); - return { valid: diagnostics.length === 0, diagnostics }; -} - function logDiagnostics(diagnostics: readonly Diagnostic[]): void { diagnostics.forEach((diagnostic) => { // eslint-disable-next-line no-console diff --git a/packages/compiler/src/server/server.ts b/packages/compiler/src/server/server.ts index e74683d0a7..0d1b61238e 100644 --- a/packages/compiler/src/server/server.ts +++ b/packages/compiler/src/server/server.ts @@ -15,7 +15,7 @@ import { import { NodeHost } from "../core/node-host.js"; import { typespecVersion } from "../utils/misc.js"; import { createServer } from "./serverlib.js"; -import { Server, ServerHost, ServerLog } from "./types.js"; +import { CustomRequestName, Server, ServerHost, ServerLog } from "./types.js"; let server: Server | undefined = undefined; @@ -129,6 +129,13 @@ function main() { connection.onExecuteCommand(profile(s.executeCommand)); connection.languages.semanticTokens.on(profile(s.buildSemanticTokens)); + const validateInitProjectTemplate: CustomRequestName = "typespec/validateInitProjectTemplate"; + connection.onRequest(validateInitProjectTemplate, profile(s.validateInitProjectTemplate)); + const getInitProjectContextRequestName: CustomRequestName = "typespec/getInitProjectContext"; + connection.onRequest(getInitProjectContextRequestName, profile(s.getInitProjectContext)); + const initProjectRequestName: CustomRequestName = "typespec/initProject"; + connection.onRequest(initProjectRequestName, profile(s.initProject)); + documents.onDidChangeContent(profile(s.checkChange)); documents.onDidClose(profile(s.documentClosed)); diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index eb47f25d09..eb484b0da8 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -50,11 +50,22 @@ import { resolveCodeFix } from "../core/code-fixes.js"; import { compilerAssert, getSourceLocation } from "../core/diagnostics.js"; import { formatTypeSpec } from "../core/formatter.js"; import { getEntityName, getTypeName } from "../core/helpers/type-name-utils.js"; -import { ProcessedLog, resolveModule, ResolveModuleHost } from "../core/index.js"; +import { + NoTarget, + ProcessedLog, + resolveModule, + ResolveModuleHost, + typespecVersion, +} from "../core/index.js"; import { formatLog } from "../core/logger/index.js"; import { getPositionBeforeTrivia } from "../core/parser-utils.js"; import { getNodeAtPosition, getNodeAtPositionDetail, visitChildren } from "../core/parser.js"; -import { ensureTrailingDirectorySeparator, getDirectoryPath } from "../core/path-utils.js"; +import { + ensureTrailingDirectorySeparator, + getDirectoryPath, + joinPaths, + normalizePath, +} from "../core/path-utils.js"; import type { Program } from "../core/program.js"; import { skipTrivia, skipWhiteSpace } from "../core/scanner.js"; import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file.js"; @@ -75,6 +86,10 @@ import { TypeReferenceNode, TypeSpecScriptNode, } from "../core/types.js"; +import { getTypeSpecCoreTemplates } from "../init/core-templates.js"; +import { validateTemplateDefinitions } from "../init/init-template-validate.js"; +import { InitTemplate } from "../init/init-template.js"; +import { scaffoldNewProject } from "../init/scaffold.js"; import { getNormalizedRealPath, resolveTspMain } from "../utils/misc.js"; import { getSemanticTokens } from "./classify.js"; import { createCompileService } from "./compile-service.js"; @@ -94,9 +109,13 @@ import { } from "./type-details.js"; import { CompileResult, + InitProjectConfig, + InitProjectContext, SemanticTokenKind, Server, + ServerCustomCapacities, ServerHost, + ServerInitializeResult, ServerLog, ServerSourceFile, ServerWorkspaceFolder, @@ -162,6 +181,10 @@ export function createServer(host: ServerHost): Server { getCodeActions, executeCommand, log, + + getInitProjectContext, + validateInitProjectTemplate, + initProject, }; async function initialize(params: InitializeParams): Promise { @@ -246,7 +269,33 @@ export function createServer(host: ServerHost): Server { } log({ level: "info", message: `Workspace Folders`, detail: workspaceFolders }); - return { capabilities }; + const customCapacities: ServerCustomCapacities = { + getInitProjectContext: true, + initProject: true, + validateInitProjectTemplate: true, + }; + // the file path is expected to be .../@typespec/compiler/dist/src/server/serverlib.js + const curFile = normalizePath(compilerHost.fileURLToPath(import.meta.url)); + const SERVERLIB_PATH_ENDWITH = "/dist/src/server/serverlib.js"; + let compilerRootFolder = undefined; + if (!curFile.endsWith(SERVERLIB_PATH_ENDWITH)) { + log({ level: "warning", message: `Unexpected path for serverlib found: ${curFile}` }); + } else { + compilerRootFolder = curFile.slice(0, curFile.length - SERVERLIB_PATH_ENDWITH.length); + } + const result: ServerInitializeResult = { + serverInfo: { + name: "TypeSpec Language Server", + version: typespecVersion, + }, + capabilities, + customCapacities, + compilerRootFolder, + compilerCliJsPath: compilerRootFolder + ? joinPaths(compilerRootFolder, "cmd", "tsp.js") + : undefined, + }; + return result; } function initialized(params: InitializedParams): void { @@ -254,6 +303,42 @@ export function createServer(host: ServerHost): Server { log({ level: "info", message: "Initialization complete." }); } + async function getInitProjectContext(): Promise { + return { + coreInitTemplates: await getTypeSpecCoreTemplates(host.compilerHost), + }; + } + + async function validateInitProjectTemplate(param: { template: InitTemplate }): Promise { + const { template } = param; + // even when the strict validation fails, we still try to proceed with relaxed validation + // so just do relaxed validation directly here + const validationResult = validateTemplateDefinitions(template, NoTarget, false); + if (!validationResult.valid) { + for (const diag of validationResult.diagnostics) { + log({ + level: diag.severity, + message: diag.message, + detail: { + code: diag.code, + url: diag.url, + }, + }); + } + } + return validationResult.valid; + } + + async function initProject(param: { config: InitProjectConfig }): Promise { + try { + await scaffoldNewProject(compilerHost, param.config); + return true; + } catch (e) { + log({ level: "error", message: "Unexpected error when initializing project", detail: e }); + return false; + } + } + async function workspaceFoldersChanged(e: WorkspaceFoldersChangeEvent) { log({ level: "info", message: "Workspace Folders Changed", detail: e }); const map = new Map(workspaceFolders.map((f) => [f.uri, f])); diff --git a/packages/compiler/src/server/types.ts b/packages/compiler/src/server/types.ts index 49381f4c33..1cbf9b6905 100644 --- a/packages/compiler/src/server/types.ts +++ b/packages/compiler/src/server/types.ts @@ -38,6 +38,9 @@ import { } from "vscode-languageserver"; import { TextDocument, TextEdit } from "vscode-languageserver-textdocument"; import type { CompilerHost, Program, SourceFile, TypeSpecScriptNode } from "../core/index.js"; +import { LoadedCoreTemplates } from "../init/core-templates.js"; +import { InitTemplate, InitTemplateLibrarySpec } from "../init/init-template.js"; +import { ScaffoldingConfig } from "../init/scaffold.js"; export type ServerLogLevel = "trace" | "debug" | "info" | "warning" | "error"; export interface ServerLog { @@ -89,6 +92,15 @@ export interface Server { getCodeActions(params: CodeActionParams): Promise; executeCommand(params: ExecuteCommandParams): Promise; log(log: ServerLog): void; + + // Following custom capacities are added for supporting tsp init project from IDE (vscode for now) so that IDE can trigger compiler + // to do the real job while collecting the necessary information accordingly from the user. + // We can't do the tsp init experience by simple cli interface because the experience needs to talk + // with the compiler for multiple times in different steps (i.e. get core templates, validate the selected template, scaffold the project) + // and it's not a good idea to expose these capacity in cli interface and call cli again and again. + getInitProjectContext(): Promise; + validateInitProjectTemplate(param: { template: InitTemplate }): Promise; + initProject(param: { config: InitProjectConfig }): Promise; } export interface ServerSourceFile extends SourceFile { @@ -135,3 +147,28 @@ export interface SemanticToken { pos: number; end: number; } + +export type CustomRequestName = + | "typespec/getInitProjectContext" + | "typespec/initProject" + | "typespec/validateInitProjectTemplate"; +export interface ServerCustomCapacities { + getInitProjectContext?: boolean; + validateInitProjectTemplate?: boolean; + initProject?: boolean; +} + +export interface ServerInitializeResult extends InitializeResult { + customCapacities?: ServerCustomCapacities; + compilerRootFolder?: string; + compilerCliJsPath?: string; +} + +export interface InitProjectContext { + /** provide the default templates current compiler/cli supports */ + coreInitTemplates: LoadedCoreTemplates; +} + +export type InitProjectConfig = ScaffoldingConfig; +export type InitProjectTemplate = InitTemplate; +export type InitProjectTemplateLibrarySpec = InitTemplateLibrarySpec; diff --git a/packages/compiler/test/e2e/init-templates.e2e.ts b/packages/compiler/test/e2e/init-templates.e2e.ts index 146f2c6c9a..6ed90c3261 100644 --- a/packages/compiler/test/e2e/init-templates.e2e.ts +++ b/packages/compiler/test/e2e/init-templates.e2e.ts @@ -6,7 +6,7 @@ import { resolve } from "path/posix"; import { fileURLToPath } from "url"; import { beforeAll, describe, it } from "vitest"; import { NodeHost } from "../../src/index.js"; -import { TypeSpecCoreTemplates } from "../../src/init/core-templates.js"; +import { getTypeSpecCoreTemplates } from "../../src/init/core-templates.js"; import { makeScaffoldingConfig, scaffoldNewProject } from "../../src/init/scaffold.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -64,7 +64,8 @@ describe("Init templates e2e tests", () => { }); async function scaffoldTemplateTo(name: string, targetFolder: string) { - const template = TypeSpecCoreTemplates.templates[name]; + const typeSpecCoreTemplates = await getTypeSpecCoreTemplates(NodeHost); + const template = typeSpecCoreTemplates.templates[name]; ok(template, `Template '${name}' not found`); await scaffoldNewProject( NodeHost, @@ -72,7 +73,7 @@ describe("Init templates e2e tests", () => { name, folderName: name, directory: targetFolder, - baseUri: TypeSpecCoreTemplates.baseUri, + baseUri: typeSpecCoreTemplates.baseUri, }), ); } diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index b1d967337a..691af914bd 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -202,11 +202,11 @@ export class CodeModelBuilder { const service = listServices(this.program)[0]; if (!service) { - this.logError("TypeSpec for HTTP must define a service."); + this.logWarning("TypeSpec for HTTP client should define a service."); } - this.serviceNamespace = service.type; + this.serviceNamespace = service?.type ?? this.program.getGlobalNamespaceType(); - this.namespace = getNamespaceFullName(this.serviceNamespace) || "Azure.Client"; + this.namespace = getNamespaceFullName(this.serviceNamespace) || "Client"; const namespace1 = this.namespace; this.typeNameOptions = { @@ -238,6 +238,10 @@ export class CodeModelBuilder { } public async build(): Promise { + if (this.program.hasError()) { + return this.codeModel; + } + this.sdkContext = await createSdkContext(this.emitterContext, "@typespec/http-client-java", { versioning: { previewStringRegex: /$/ }, }); // include all versions and do the filter by ourselves @@ -257,9 +261,8 @@ export class CodeModelBuilder { this.codeModel.language.java!.namespace = this.baseJavaNamespace; - // TODO: reportDiagnostics from TCGC temporary disabled - // issue https://github.com/Azure/typespec-azure/issues/1675 - // this.program.reportDiagnostics(this.sdkContext.diagnostics); + // potential problem https://github.com/Azure/typespec-azure/issues/1675 + this.program.reportDiagnostics(this.sdkContext.diagnostics); // auth // TODO: it is not very likely, but different client could have different auth diff --git a/packages/http-client-java/emitter/src/emitter.ts b/packages/http-client-java/emitter/src/emitter.ts index 625e87e957..ca4246c473 100644 --- a/packages/http-client-java/emitter/src/emitter.ts +++ b/packages/http-client-java/emitter/src/emitter.ts @@ -15,7 +15,6 @@ import { JDK_NOT_FOUND_MESSAGE, validateDependencies } from "./validate.js"; export interface EmitterOptions { namespace?: string; - "output-dir"?: string; "package-dir"?: string; flavor?: string; @@ -57,12 +56,16 @@ export interface DevOptions { "java-temp-dir"?: string; // working directory for java codegen, e.g. transformed code-model file } +type CodeModelEmitterOptions = EmitterOptions & { + "output-dir": string; + arm?: boolean; +}; + const EmitterOptionsSchema: JSONSchemaType = { type: "object", additionalProperties: true, properties: { namespace: { type: "string", nullable: true }, - "output-dir": { type: "string", nullable: true }, "package-dir": { type: "string", nullable: true }, flavor: { type: "string", nullable: true }, @@ -118,8 +121,7 @@ export async function $onEmit(context: EmitContext) { if (!program.hasError()) { const options = context.options; if (!options["flavor"]) { - if (options["package-dir"]?.toLocaleLowerCase().startsWith("azure")) { - // Azure package + if ($lib.name === "@azure-tools/typespec-java") { options["flavor"] = "azure"; } } @@ -130,10 +132,13 @@ export async function $onEmit(context: EmitContext) { const __dirname = dirname(fileURLToPath(import.meta.url)); const moduleRoot = resolvePath(__dirname, "..", ".."); - const outputPath = options["output-dir"] ?? context.emitterOutputDir; - options["output-dir"] = getNormalizedAbsolutePath(outputPath, undefined); + const outputPath = context.emitterOutputDir; + (options as CodeModelEmitterOptions)["output-dir"] = getNormalizedAbsolutePath( + outputPath, + undefined, + ); - (options as any)["arm"] = codeModel.arm; + (options as CodeModelEmitterOptions).arm = codeModel.arm; const codeModelFileName = resolvePath(outputPath, "./code-model.yaml"); diff --git a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/StreamSerializationModelTemplate.java b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/StreamSerializationModelTemplate.java index 53cc20f72f..57a99b1395 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/StreamSerializationModelTemplate.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/StreamSerializationModelTemplate.java @@ -2312,10 +2312,20 @@ private void writeFromXmlDeserialization(JavaBlock methodBlock) { // Loop over all properties and generate their deserialization handling. AtomicReference ifBlockReference = new AtomicReference<>(ifBlock); - propertiesManager.forEachSuperXmlElement( - element -> handleXmlPropertyDeserialization(element, whileBlock, ifBlockReference, true)); - propertiesManager.forEachXmlElement( - element -> handleXmlPropertyDeserialization(element, whileBlock, ifBlockReference, false)); + propertiesManager.forEachSuperXmlElement(element -> { + if (element.isRequired() && element.isConstant()) { + return; + } + handleXmlPropertyDeserialization(element, whileBlock, ifBlockReference, true); + }); + propertiesManager.forEachXmlElement(element -> { + if (element.isRequired() && element.isConstant()) { + // the element is element of a constant, which can only have one value + // skip de-serialize + return; + } + handleXmlPropertyDeserialization(element, whileBlock, ifBlockReference, false); + }); ifBlock = ifBlockReference.get(); diff --git a/packages/http-client-java/generator/http-client-generator-test/package.json b/packages/http-client-java/generator/http-client-generator-test/package.json index 65332739e6..a8ca8b4145 100644 --- a/packages/http-client-java/generator/http-client-generator-test/package.json +++ b/packages/http-client-java/generator/http-client-generator-test/package.json @@ -14,7 +14,7 @@ "dependencies": { "@typespec/http-specs": "0.1.0-alpha.5", "@azure-tools/azure-http-specs": "0.1.0-alpha.4", - "@typespec/http-client-java": "file:/../../typespec-http-client-java-0.1.4.tgz", + "@typespec/http-client-java": "file:../../typespec-http-client-java-0.1.5.tgz", "@typespec/http-client-java-tests": "file:" }, "overrides": { diff --git a/packages/http-client-java/package-lock.json b/packages/http-client-java/package-lock.json index 5bbcfa2a3d..33bb25f262 100644 --- a/packages/http-client-java/package-lock.json +++ b/packages/http-client-java/package-lock.json @@ -1,12 +1,12 @@ { "name": "@typespec/http-client-java", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@typespec/http-client-java", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "@autorest/codemodel": "~4.20.0", diff --git a/packages/http-client-java/package.json b/packages/http-client-java/package.json index b75e74490d..5367482a48 100644 --- a/packages/http-client-java/package.json +++ b/packages/http-client-java/package.json @@ -1,6 +1,6 @@ { "name": "@typespec/http-client-java", - "version": "0.1.4", + "version": "0.1.5", "description": "TypeSpec library for emitting Java client from the TypeSpec REST protocol binding", "keywords": [ "TypeSpec" diff --git a/packages/typespec-vs/src/Microsoft.TypeSpec.VS.csproj b/packages/typespec-vs/src/Microsoft.TypeSpec.VS.csproj index 5d2bb081a1..b81c359959 100644 --- a/packages/typespec-vs/src/Microsoft.TypeSpec.VS.csproj +++ b/packages/typespec-vs/src/Microsoft.TypeSpec.VS.csproj @@ -7,6 +7,9 @@ false Latest Enable + + + all true 42.42.42 @@ -28,6 +31,10 @@ Link="TextMate/typespec.tmLanguage" /> + + + + Always @@ -40,12 +47,18 @@ + + + + - - - + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/packages/typespec-vscode/package.json b/packages/typespec-vscode/package.json index 722b29702e..52fc4e906b 100644 --- a/packages/typespec-vscode/package.json +++ b/packages/typespec-vscode/package.json @@ -32,10 +32,18 @@ "activationEvents": [ "onLanguage:typespec", "onCommand:typespec.restartServer", + "onCommand:typespec.createProject", "workspaceContains:**/tspconfig.yaml" ], "icon": "./icons/logo.png", "contributes": { + "viewsWelcome": [ + { + "view": "explorer", + "contents": "You may [open a folder](command:vscode.openFolder) of an existing TypeSpec project; or create a new TypeSpec project in VS Code.\n[Create TypeSpec Project](command:typespec.createProject)\nTo manually create a TypeSpec project, follow [this guide](https://typespec.io/docs/).", + "when": "!workspaceFolderCount" + } + ], "languages": [ { "id": "typespec", @@ -66,6 +74,30 @@ "description": "Path to `tsp-server` command that runs the TypeSpec language server.\n\nIf not specified, then `tsp-server` found on PATH is used.\n\nExample (User): /usr/local/bin/tsp-server\nExample (Workspace): ${workspaceFolder}/node_modules/@typespec/compiler", "scope": "machine-overridable" }, + "typespec.initTemplatesUrls": { + "type": "array", + "default": [], + "description": "List of URLs to fetch templates from when creating a new project.", + "scope": "machine-overridable", + "items": { + "type": "object", + "required": [ + "name", + "url" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the template." + }, + "url": { + "type": "string", + "description": "URL to fetch the template from." + } + }, + "additionalProperties": false + } + }, "typespec.trace.server": { "scope": "window", "type": "string", @@ -108,6 +140,16 @@ "command": "typespec.showOutputChannel", "title": "Show Output Channel", "category": "TypeSpec" + }, + { + "command": "typespec.createProject", + "title": "Create TypeSpec Project", + "category": "TypeSpec" + }, + { + "command": "typespec.installGlobalCompilerCli", + "title": "Install TypeSpec Compiler/CLI globally", + "category": "TypeSpec" } ], "semanticTokenScopes": [ @@ -173,6 +215,7 @@ "@types/mocha": "^10.0.9", "@types/node": "~22.7.9", "@types/vscode": "~1.94.0", + "@types/semver": "^7.5.8", "@typespec/compiler": "workspace:~", "@typespec/internal-build-utils": "workspace:~", "@vitest/coverage-v8": "^2.1.5", @@ -185,6 +228,7 @@ "rollup": "~4.24.0", "typescript": "~5.6.3", "vitest": "^2.1.5", - "vscode-languageclient": "~9.0.1" + "vscode-languageclient": "~9.0.1", + "semver": "^7.6.3" } } diff --git a/packages/typespec-vscode/src/code-action-provider.ts b/packages/typespec-vscode/src/code-action-provider.ts index 803242b392..218c765f5d 100644 --- a/packages/typespec-vscode/src/code-action-provider.ts +++ b/packages/typespec-vscode/src/code-action-provider.ts @@ -1,5 +1,5 @@ import vscode from "vscode"; -import { OPEN_URL_COMMAND } from "./vscode-command.js"; +import { CommandName } from "./types.js"; export function createCodeActionProvider() { return vscode.languages.registerCodeActionsProvider( @@ -61,7 +61,7 @@ export class TypeSpecCodeActionProvider implements vscode.CodeActionProvider { vscode.CodeActionKind.QuickFix, ); action.command = { - command: OPEN_URL_COMMAND, + command: CommandName.OpenUrl, title: diagnostic.message, arguments: [url], }; diff --git a/packages/typespec-vscode/src/const.ts b/packages/typespec-vscode/src/const.ts deleted file mode 100644 index c2d3e7cec6..0000000000 --- a/packages/typespec-vscode/src/const.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const enum SettingName { - TspServerPath = "typespec.tsp-server.path", -} diff --git a/packages/typespec-vscode/src/extension.ts b/packages/typespec-vscode/src/extension.ts index 7fe2a5f468..6495b6bbd0 100644 --- a/packages/typespec-vscode/src/extension.ts +++ b/packages/typespec-vscode/src/extension.ts @@ -1,12 +1,19 @@ import vscode, { commands, ExtensionContext } from "vscode"; +import { State } from "vscode-languageclient"; import { createCodeActionProvider } from "./code-action-provider.js"; -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"; -import { createCommandOpenUrl } from "./vscode-command.js"; +import { + CommandName, + InstallGlobalCliCommandArgs, + RestartServerCommandArgs, + SettingName, +} from "./types.js"; +import { createTypeSpecProject } from "./vscode-cmd/create-tsp-project.js"; +import { installCompilerGlobally } from "./vscode-cmd/install-tsp-compiler.js"; let client: TspLanguageClient | undefined; /** @@ -20,30 +27,72 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(createTaskProvider()); context.subscriptions.push(createCodeActionProvider()); - context.subscriptions.push(createCommandOpenUrl()); context.subscriptions.push( - commands.registerCommand("typespec.showOutputChannel", () => { + commands.registerCommand(CommandName.ShowOutputChannel, () => { outputChannel.show(true /*preserveFocus*/); }), ); context.subscriptions.push( - commands.registerCommand("typespec.restartServer", async () => { - if (client) { - await client.restart(); + commands.registerCommand(CommandName.OpenUrl, (url: string) => { + try { + vscode.env.openExternal(vscode.Uri.parse(url)); + } catch (error) { + logger.error(`Failed to open URL: ${url}`, [error as any]); } }), ); + context.subscriptions.push( + commands.registerCommand( + CommandName.RestartServer, + async (args: RestartServerCommandArgs | undefined): Promise => { + return vscode.window.withProgress( + { + title: "Restarting TypeSpec language service...", + location: vscode.ProgressLocation.Notification, + }, + async () => { + if (args?.forceRecreate === true) { + logger.info("Forcing to recreate TypeSpec LSP server..."); + return await recreateLSPClient(context, args?.popupRecreateLspError); + } + if (client && client.state === State.Running) { + await client.restart(); + return client; + } else { + logger.info( + "TypeSpec LSP server is not running which is not expected, try to recreate and start...", + ); + return recreateLSPClient(context, args?.popupRecreateLspError); + } + }, + ); + }, + ), + ); + + context.subscriptions.push( + commands.registerCommand( + CommandName.InstallGlobalCompilerCli, + async (args: InstallGlobalCliCommandArgs | undefined) => { + return await installCompilerGlobally(args); + }, + ), + ); + + context.subscriptions.push( + commands.registerCommand(CommandName.CreateProject, async () => { + await createTypeSpecProject(client); + }), + ); + 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(); + await recreateLSPClient(context); } }), ); @@ -54,8 +103,7 @@ export async function activate(context: ExtensionContext) { location: vscode.ProgressLocation.Notification, }, async () => { - client = await TspLanguageClient.create(context, outputChannel); - await client.start(); + await recreateLSPClient(context); }, ); } @@ -63,3 +111,12 @@ export async function activate(context: ExtensionContext) { export async function deactivate() { await client?.stop(); } + +async function recreateLSPClient(context: ExtensionContext, showPopupWhenError?: boolean) { + logger.info("Recreating TypeSpec LSP server..."); + const oldClient = client; + client = await TspLanguageClient.create(context, outputChannel); + await oldClient?.stop(); + await client.start(showPopupWhenError ?? (vscode.workspace.workspaceFolders?.length ?? 0) > 0); + return client; +} diff --git a/packages/typespec-vscode/src/path-utils.ts b/packages/typespec-vscode/src/path-utils.ts new file mode 100644 index 0000000000..4235b83303 --- /dev/null +++ b/packages/typespec-vscode/src/path-utils.ts @@ -0,0 +1,766 @@ +// Use the same path-utils as compiler (packages\compiler\src\core\path-utils.ts) to make sure we are using the same path resolution logic. +// Forked from https://github.com/microsoft/TypeScript/blob/663b19fe4a7c4d4ddaa61aedadd28da06acd27b6/src/compiler/path.ts + +/** + * Internally, we represent paths as strings with '/' as the directory separator. + * When we make system calls (eg: LanguageServiceHost.getDirectory()), + * we expect the host to correctly handle paths in our specified format. + */ +export const directorySeparator = "/"; +export const altDirectorySeparator = "\\"; +const urlSchemeSeparator = "://"; +const backslashRegExp = /\\/g; +const relativePathSegmentRegExp = /(?:\/\/)|(?:^|\/)\.\.?(?:$|\/)/; + +//#region Path Tests +/** + * Determines whether a charCode corresponds to `/` or `\`. + */ +export function isAnyDirectorySeparator(charCode: number): boolean { + return charCode === CharacterCodes.slash || charCode === CharacterCodes.backslash; +} + +/** + * Determines whether a path starts with a URL scheme (e.g. starts with `http://`, `ftp://`, `file://`, etc.). + */ +export function isUrl(path: string) { + return getEncodedRootLength(path) < 0; +} + +/* + * Determines whether a path starts with an absolute path component (i.e. `/`, `c:/`, `file://`, etc.). + * + * ```ts + * // POSIX + * isPathAbsolute("/path/to/file.ext") === true + * // DOS + * isPathAbsolute("c:/path/to/file.ext") === true + * // URL + * isPathAbsolute("file:///path/to/file.ext") === true + * // Non-absolute + * isPathAbsolute("path/to/file.ext") === false + * isPathAbsolute("./path/to/file.ext") === false + * ``` + */ +export function isPathAbsolute(path: string): boolean { + return getEncodedRootLength(path) !== 0; +} +//#endregion + +//#region Path Parsing + +function isVolumeCharacter(charCode: number) { + return ( + (charCode >= CharacterCodes.a && charCode <= CharacterCodes.z) || + (charCode >= CharacterCodes.A && charCode <= CharacterCodes.Z) + ); +} + +function getFileUrlVolumeSeparatorEnd(url: string, start: number) { + const ch0 = url.charCodeAt(start); + if (ch0 === CharacterCodes.colon) return start + 1; + if (ch0 === CharacterCodes.percent && url.charCodeAt(start + 1) === CharacterCodes._3) { + const ch2 = url.charCodeAt(start + 2); + if (ch2 === CharacterCodes.a || ch2 === CharacterCodes.A) return start + 3; + } + return -1; +} + +/** + * Returns length of the root part of a path or URL (i.e. length of "/", "x:/", "//server/share/, file:///user/files"). + * + * For example: + * ```ts + * getRootLength("a") === 0 // "" + * getRootLength("/") === 1 // "/" + * getRootLength("c:") === 2 // "c:" + * getRootLength("c:d") === 0 // "" + * getRootLength("c:/") === 3 // "c:/" + * getRootLength("c:\\") === 3 // "c:\\" + * getRootLength("//server") === 7 // "//server" + * getRootLength("//server/share") === 8 // "//server/" + * getRootLength("\\\\server") === 7 // "\\\\server" + * getRootLength("\\\\server\\share") === 8 // "\\\\server\\" + * getRootLength("file:///path") === 8 // "file:///" + * getRootLength("file:///c:") === 10 // "file:///c:" + * getRootLength("file:///c:d") === 8 // "file:///" + * getRootLength("file:///c:/path") === 11 // "file:///c:/" + * getRootLength("file://server") === 13 // "file://server" + * getRootLength("file://server/path") === 14 // "file://server/" + * getRootLength("http://server") === 13 // "http://server" + * getRootLength("http://server/path") === 14 // "http://server/" + * ``` + */ +export function getRootLength(path: string) { + const rootLength = getEncodedRootLength(path); + return rootLength < 0 ? ~rootLength : rootLength; +} +/** + * Returns length of the root part of a path or URL (i.e. length of "/", "x:/", "//server/share/, file:///user/files"). + * If the root is part of a URL, the twos-complement of the root length is returned. + */ +function getEncodedRootLength(path: string): number { + if (!path) return 0; + const ch0 = path.charCodeAt(0); + + // POSIX or UNC + if (ch0 === CharacterCodes.slash || ch0 === CharacterCodes.backslash) { + if (path.charCodeAt(1) !== ch0) return 1; // POSIX: "/" (or non-normalized "\") + + const p1 = path.indexOf( + ch0 === CharacterCodes.slash ? directorySeparator : altDirectorySeparator, + 2, + ); + if (p1 < 0) return path.length; // UNC: "//server" or "\\server" + + return p1 + 1; // UNC: "//server/" or "\\server\" + } + + // DOS + if (isVolumeCharacter(ch0) && path.charCodeAt(1) === CharacterCodes.colon) { + const ch2 = path.charCodeAt(2); + if (ch2 === CharacterCodes.slash || ch2 === CharacterCodes.backslash) return 3; // DOS: "c:/" or "c:\" + if (path.length === 2) return 2; // DOS: "c:" (but not "c:d") + } + + // URL + const schemeEnd = path.indexOf(urlSchemeSeparator); + if (schemeEnd !== -1) { + const authorityStart = schemeEnd + urlSchemeSeparator.length; + const authorityEnd = path.indexOf(directorySeparator, authorityStart); + if (authorityEnd !== -1) { + // URL: "file:///", "file://server/", "file://server/path" + // For local "file" URLs, include the leading DOS volume (if present). + // Per https://www.ietf.org/rfc/rfc1738.txt, a host of "" or "localhost" is a + // special case interpreted as "the machine from which the URL is being interpreted". + const scheme = path.slice(0, schemeEnd); + const authority = path.slice(authorityStart, authorityEnd); + if ( + scheme === "file" && + (authority === "" || authority === "localhost") && + isVolumeCharacter(path.charCodeAt(authorityEnd + 1)) + ) { + const volumeSeparatorEnd = getFileUrlVolumeSeparatorEnd(path, authorityEnd + 2); + if (volumeSeparatorEnd !== -1) { + if (path.charCodeAt(volumeSeparatorEnd) === CharacterCodes.slash) { + // URL: "file:///c:/", "file://localhost/c:/", "file:///c%3a/", "file://localhost/c%3a/" + return ~(volumeSeparatorEnd + 1); + } + if (volumeSeparatorEnd === path.length) { + // URL: "file:///c:", "file://localhost/c:", "file:///c$3a", "file://localhost/c%3a" + // but not "file:///c:d" or "file:///c%3ad" + return ~volumeSeparatorEnd; + } + } + } + return ~(authorityEnd + 1); // URL: "file://server/", "http://server/" + } + return ~path.length; // URL: "file://server", "http://server" + } + + // relative + return 0; +} + +export function getDirectoryPath(path: string): string { + path = normalizeSlashes(path); + + // If the path provided is itself the root, then return it. + const rootLength = getRootLength(path); + if (rootLength === path.length) return path; + + // return the leading portion of the path up to the last (non-terminal) directory separator + // but not including any trailing directory separator. + path = removeTrailingDirectorySeparator(path); + return path.slice(0, Math.max(rootLength, path.lastIndexOf(directorySeparator))); +} + +/** + * Returns the path except for its containing directory name. + * Semantics align with NodeJS's `path.basename` except that we support URL's as well. + * + * ```ts + * // POSIX + * getBaseFileName("/path/to/file.ext") === "file.ext" + * getBaseFileName("/path/to/") === "to" + * getBaseFileName("/") === "" + * // DOS + * getBaseFileName("c:/path/to/file.ext") === "file.ext" + * getBaseFileName("c:/path/to/") === "to" + * getBaseFileName("c:/") === "" + * getBaseFileName("c:") === "" + * // URL + * getBaseFileName("http://typescriptlang.org/path/to/file.ext") === "file.ext" + * getBaseFileName("http://typescriptlang.org/path/to/") === "to" + * getBaseFileName("http://typescriptlang.org/") === "" + * getBaseFileName("http://typescriptlang.org") === "" + * getBaseFileName("file://server/path/to/file.ext") === "file.ext" + * getBaseFileName("file://server/path/to/") === "to" + * getBaseFileName("file://server/") === "" + * getBaseFileName("file://server") === "" + * getBaseFileName("file:///path/to/file.ext") === "file.ext" + * getBaseFileName("file:///path/to/") === "to" + * getBaseFileName("file:///") === "" + * getBaseFileName("file://") === "" + * ``` + */ +export function getBaseFileName(path: string): string { + path = normalizeSlashes(path); + + // if the path provided is itself the root, then it has not file name. + const rootLength = getRootLength(path); + if (rootLength === path.length) return ""; + + // return the trailing portion of the path starting after the last (non-terminal) directory + // separator but not including any trailing directory separator. + path = removeTrailingDirectorySeparator(path); + return path.slice(Math.max(getRootLength(path), path.lastIndexOf(directorySeparator) + 1)); +} + +/** + * Gets the file extension for a path. + * Normalizes it to lower case. + * + * ```ts + * getAnyExtensionFromPath("/path/to/file.ext") === ".ext" + * getAnyExtensionFromPath("/path/to/file.ext/") === ".ext" + * getAnyExtensionFromPath("/path/to/file") === "" + * getAnyExtensionFromPath("/path/to.ext/file") === "" + * ``` + */ +export function getAnyExtensionFromPath(path: string): string { + // Retrieves any string from the final "." onwards from a base file name. + // Unlike extensionFromPath, which throws an exception on unrecognized extensions. + const baseFileName = getBaseFileName(path); + const extensionIndex = baseFileName.lastIndexOf("."); + if (extensionIndex >= 0) { + return baseFileName.substring(extensionIndex).toLowerCase(); + } + return ""; +} + +function pathComponents(path: string, rootLength: number) { + const root = path.substring(0, rootLength); + const rest = path.substring(rootLength).split(directorySeparator); + if (rest.length && !rest[rest.length - 1]) rest.pop(); + return [root, ...rest]; +} + +/** + * Parse a path into an array containing a root component (at index 0) and zero or more path + * components (at indices > 0). The result is not normalized. + * If the path is relative, the root component is `""`. + * If the path is absolute, the root component includes the first path separator (`/`). + * + * ```ts + * // POSIX + * getPathComponents("/path/to/file.ext") === ["/", "path", "to", "file.ext"] + * getPathComponents("/path/to/") === ["/", "path", "to"] + * getPathComponents("/") === ["/"] + * // DOS + * getPathComponents("c:/path/to/file.ext") === ["c:/", "path", "to", "file.ext"] + * getPathComponents("c:/path/to/") === ["c:/", "path", "to"] + * getPathComponents("c:/") === ["c:/"] + * getPathComponents("c:") === ["c:"] + * // URL + * getPathComponents("http://typescriptlang.org/path/to/file.ext") === ["http://typescriptlang.org/", "path", "to", "file.ext"] + * getPathComponents("http://typescriptlang.org/path/to/") === ["http://typescriptlang.org/", "path", "to"] + * getPathComponents("http://typescriptlang.org/") === ["http://typescriptlang.org/"] + * getPathComponents("http://typescriptlang.org") === ["http://typescriptlang.org"] + * getPathComponents("file://server/path/to/file.ext") === ["file://server/", "path", "to", "file.ext"] + * getPathComponents("file://server/path/to/") === ["file://server/", "path", "to"] + * getPathComponents("file://server/") === ["file://server/"] + * getPathComponents("file://server") === ["file://server"] + * getPathComponents("file:///path/to/file.ext") === ["file:///", "path", "to", "file.ext"] + * getPathComponents("file:///path/to/") === ["file:///", "path", "to"] + * getPathComponents("file:///") === ["file:///"] + * getPathComponents("file://") === ["file://"] + * ``` + */ +export function getPathComponents(path: string, currentDirectory = "") { + path = joinPaths(currentDirectory, path); + return pathComponents(path, getRootLength(path)); +} + +//#endregion + +//#region Path Formatting +/** + * Reduce an array of path components to a more simplified path by navigating any + * `"."` or `".."` entries in the path. + */ +export function reducePathComponents(components: readonly string[]) { + if (!components.some((x) => x !== undefined)) return []; + const reduced = [components[0]]; + for (let i = 1; i < components.length; i++) { + const component = components[i]; + if (!component) continue; + if (component === ".") continue; + if (component === "..") { + if (reduced.length > 1) { + if (reduced[reduced.length - 1] !== "..") { + reduced.pop(); + continue; + } + } else if (reduced[0]) continue; + } + reduced.push(component); + } + return reduced; +} + +/** + * Combines paths. If a path is absolute, it replaces any previous path. Relative paths are not simplified. + * + * ```ts + * // Non-rooted + * joinPaths("path", "to", "file.ext") === "path/to/file.ext" + * joinPaths("path", "dir", "..", "to", "file.ext") === "path/dir/../to/file.ext" + * // POSIX + * joinPaths("/path", "to", "file.ext") === "/path/to/file.ext" + * joinPaths("/path", "/to", "file.ext") === "/to/file.ext" + * // DOS + * joinPaths("c:/path", "to", "file.ext") === "c:/path/to/file.ext" + * joinPaths("c:/path", "c:/to", "file.ext") === "c:/to/file.ext" + * // URL + * joinPaths("file:///path", "to", "file.ext") === "file:///path/to/file.ext" + * joinPaths("file:///path", "file:///to", "file.ext") === "file:///to/file.ext" + * ``` + */ +export function joinPaths(path: string, ...paths: (string | undefined)[]): string { + if (path) path = normalizeSlashes(path); + for (let relativePath of paths) { + if (!relativePath) continue; + relativePath = normalizeSlashes(relativePath); + if (!path || getRootLength(relativePath) !== 0) { + path = relativePath; + } else { + path = ensureTrailingDirectorySeparator(path) + relativePath; + } + } + return path; +} + +/** + * Combines and resolves paths. If a path is absolute, it replaces any previous path. Any + * `.` and `..` path components are resolved. Trailing directory separators are preserved. + * + * ```ts + * resolvePath("/path", "to", "file.ext") === "path/to/file.ext" + * resolvePath("/path", "to", "file.ext/") === "path/to/file.ext/" + * resolvePath("/path", "dir", "..", "to", "file.ext") === "path/to/file.ext" + * ``` + */ +export function resolvePath(path: string, ...paths: (string | undefined)[]): string { + return normalizePath( + paths.some((x) => x !== undefined) ? joinPaths(path, ...paths) : normalizeSlashes(path), + ); +} + +/** + * Parse a path into an array containing a root component (at index 0) and zero or more path + * components (at indices > 0). The result is normalized. + * If the path is relative, the root component is `""`. + * If the path is absolute, the root component includes the first path separator (`/`). + * + * ```ts + * getNormalizedPathComponents("to/dir/../file.ext", "/path/") === ["/", "path", "to", "file.ext"] + * ``` + */ +export function getNormalizedPathComponents(path: string, currentDirectory: string | undefined) { + return reducePathComponents(getPathComponents(path, currentDirectory)); +} + +export function getNormalizedAbsolutePath(fileName: string, currentDirectory: string | undefined) { + return getPathFromPathComponents(getNormalizedPathComponents(fileName, currentDirectory)); +} + +export function normalizePath(path: string): string { + path = normalizeSlashes(path); + // Most paths don't require normalization + if (!relativePathSegmentRegExp.test(path)) { + return path; + } + // Some paths only require cleanup of `/./` or leading `./` + const simplified = path.replace(/\/\.\//g, "/").replace(/^\.\//, ""); + if (simplified !== path) { + path = simplified; + if (!relativePathSegmentRegExp.test(path)) { + return path; + } + } + // Other paths require full normalization + const normalized = getPathFromPathComponents(reducePathComponents(getPathComponents(path))); + return normalized && hasTrailingDirectorySeparator(path) + ? ensureTrailingDirectorySeparator(normalized) + : normalized; +} + +//#endregion + +function getPathWithoutRoot(pathComponents: readonly string[]) { + if (pathComponents.length === 0) return ""; + return pathComponents.slice(1).join(directorySeparator); +} + +export function getNormalizedAbsolutePathWithoutRoot( + fileName: string, + currentDirectory: string | undefined, +) { + return getPathWithoutRoot(getNormalizedPathComponents(fileName, currentDirectory)); +} + +/** + * Formats a parsed path consisting of a root component (at index 0) and zero or more path + * segments (at indices > 0). + * + * ```ts + * getPathFromPathComponents(["/", "path", "to", "file.ext"]) === "/path/to/file.ext" + * ``` + */ +export function getPathFromPathComponents(pathComponents: readonly string[]) { + if (pathComponents.length === 0) return ""; + + const root = pathComponents[0] && ensureTrailingDirectorySeparator(pathComponents[0]); + return root + pathComponents.slice(1).join(directorySeparator); +} + +//#region Path mutation +/** + * Removes a trailing directory separator from a path, if it does not already have one. + * + * ```ts + * removeTrailingDirectorySeparator("/path/to/file.ext") === "/path/to/file.ext" + * removeTrailingDirectorySeparator("/path/to/file.ext/") === "/path/to/file.ext" + * ``` + */ +export function removeTrailingDirectorySeparator(path: string): string; +export function removeTrailingDirectorySeparator(path: string) { + if (hasTrailingDirectorySeparator(path)) { + return path.substring(0, path.length - 1); + } + + return path; +} + +export function ensureTrailingDirectorySeparator(path: string): string { + if (!hasTrailingDirectorySeparator(path)) { + return path + directorySeparator; + } + + return path; +} + +/** + * Determines whether a path has a trailing separator (`/` or `\\`). + */ +export function hasTrailingDirectorySeparator(path: string) { + return path.length > 0 && isAnyDirectorySeparator(path.charCodeAt(path.length - 1)); +} + +/** + * Normalize path separators, converting `\` into `/`. + */ +export function normalizeSlashes(path: string): string { + const index = path.indexOf("\\"); + if (index === -1) { + return path; + } + backslashRegExp.lastIndex = index; // prime regex with known position + return path.replace(backslashRegExp, directorySeparator); +} + +//#endregion +// #region relative paths +type GetCanonicalFileName = (fileName: string) => string; + +/** @internal */ +function equateValues(a: T, b: T) { + return a === b; +} + +/** + * Compare the equality of two strings using a case-sensitive ordinal comparison. + * + * Case-sensitive comparisons compare both strings one code-point at a time using the integer + * value of each code-point after applying `toUpperCase` to each string. We always map both + * strings to their upper-case form as some unicode characters do not properly round-trip to + * lowercase (such as `ẞ` (German sharp capital s)). + * + * @internal + */ +function equateStringsCaseInsensitive(a: string, b: string) { + return a === b || (a !== undefined && b !== undefined && a.toUpperCase() === b.toUpperCase()); +} + +/** + * Compare the equality of two strings using a case-sensitive ordinal comparison. + * + * Case-sensitive comparisons compare both strings one code-point at a time using the + * integer value of each code-point. + * + * @internal + */ +function equateStringsCaseSensitive(a: string, b: string) { + return equateValues(a, b); +} + +/** + * Returns its argument. + * + * @internal + */ +function identity(x: T) { + return x; +} + +/** + * Determines whether a path starts with an absolute path component (i.e. `/`, `c:/`, `file://`, etc.). + * + * ```ts + * // POSIX + * pathIsAbsolute("/path/to/file.ext") === true + * // DOS + * pathIsAbsolute("c:/path/to/file.ext") === true + * // URL + * pathIsAbsolute("file:///path/to/file.ext") === true + * // Non-absolute + * pathIsAbsolute("path/to/file.ext") === false + * pathIsAbsolute("./path/to/file.ext") === false + * ``` + * + * @internal + */ +function pathIsAbsolute(path: string): boolean { + return getEncodedRootLength(path) !== 0; +} + +/** + * Determines whether a path starts with a relative path component (i.e. `.` or `..`). + * + * @internal + */ +function pathIsRelative(path: string): boolean { + return /^\.\.?($|[\\/])/.test(path); +} + +/** + * Ensures a path is either absolute (prefixed with `/` or `c:`) or dot-relative (prefixed + * with `./` or `../`) so as not to be confused with an unprefixed module name. + * + * ```ts + * ensurePathIsNonModuleName("/path/to/file.ext") === "/path/to/file.ext" + * ensurePathIsNonModuleName("./path/to/file.ext") === "./path/to/file.ext" + * ensurePathIsNonModuleName("../path/to/file.ext") === "../path/to/file.ext" + * ensurePathIsNonModuleName("path/to/file.ext") === "./path/to/file.ext" + * ``` + * + * @internal + */ +export function ensurePathIsNonModuleName(path: string): string { + return !pathIsAbsolute(path) && !pathIsRelative(path) ? "./" + path : path; +} + +/** @internal */ +function getPathComponentsRelativeTo( + from: string, + to: string, + stringEqualityComparer: (a: string, b: string) => boolean, + getCanonicalFileName: GetCanonicalFileName, +) { + const fromComponents = reducePathComponents(getPathComponents(from)); + const toComponents = reducePathComponents(getPathComponents(to)); + + let start: number; + for (start = 0; start < fromComponents.length && start < toComponents.length; start++) { + const fromComponent = getCanonicalFileName(fromComponents[start]); + const toComponent = getCanonicalFileName(toComponents[start]); + const comparer = start === 0 ? equateStringsCaseInsensitive : stringEqualityComparer; + if (!comparer(fromComponent, toComponent)) break; + } + + if (start === 0) { + return toComponents; + } + + const components = toComponents.slice(start); + const relative: string[] = []; + for (; start < fromComponents.length; start++) { + relative.push(".."); + } + return ["", ...relative, ...components]; +} + +/** + * Gets a relative path that can be used to traverse between `from` and `to`. + */ +export function getRelativePathFromDirectory(from: string, to: string, ignoreCase: boolean): string; +/** + * Gets a relative path that can be used to traverse between `from` and `to`. + */ +export function getRelativePathFromDirectory( + fromDirectory: string, + to: string, + getCanonicalFileName: GetCanonicalFileName, +): string; +export function getRelativePathFromDirectory( + fromDirectory: string, + to: string, + getCanonicalFileNameOrIgnoreCase: GetCanonicalFileName | boolean, +) { + if (getRootLength(fromDirectory) > 0 !== getRootLength(to) > 0) { + throw new Error("Paths must either both be absolute or both be relative"); + } + const getCanonicalFileName = + typeof getCanonicalFileNameOrIgnoreCase === "function" + ? getCanonicalFileNameOrIgnoreCase + : identity; + const ignoreCase = + typeof getCanonicalFileNameOrIgnoreCase === "boolean" + ? getCanonicalFileNameOrIgnoreCase + : false; + const pathComponents = getPathComponentsRelativeTo( + fromDirectory, + to, + ignoreCase ? equateStringsCaseInsensitive : equateStringsCaseSensitive, + getCanonicalFileName, + ); + return getPathFromPathComponents(pathComponents); +} + +// #endregion +const enum CharacterCodes { + nullCharacter = 0, + maxAsciiCharacter = 0x7f, + + lineFeed = 0x0a, // \n + carriageReturn = 0x0d, // \r + lineSeparator = 0x2028, + paragraphSeparator = 0x2029, + nextLine = 0x0085, + + // Unicode 3.0 space characters + space = 0x0020, // " " + nonBreakingSpace = 0x00a0, // + enQuad = 0x2000, + emQuad = 0x2001, + enSpace = 0x2002, + emSpace = 0x2003, + threePerEmSpace = 0x2004, + fourPerEmSpace = 0x2005, + sixPerEmSpace = 0x2006, + figureSpace = 0x2007, + punctuationSpace = 0x2008, + thinSpace = 0x2009, + hairSpace = 0x200a, + zeroWidthSpace = 0x200b, + narrowNoBreakSpace = 0x202f, + ideographicSpace = 0x3000, + mathematicalSpace = 0x205f, + ogham = 0x1680, + + _ = 0x5f, + $ = 0x24, + + _0 = 0x30, + _1 = 0x31, + _2 = 0x32, + _3 = 0x33, + _4 = 0x34, + _5 = 0x35, + _6 = 0x36, + _7 = 0x37, + _8 = 0x38, + _9 = 0x39, + + a = 0x61, + b = 0x62, + c = 0x63, + d = 0x64, + e = 0x65, + f = 0x66, + g = 0x67, + h = 0x68, + i = 0x69, + j = 0x6a, + k = 0x6b, + l = 0x6c, + m = 0x6d, + n = 0x6e, + o = 0x6f, + p = 0x70, + q = 0x71, + r = 0x72, + s = 0x73, + t = 0x74, + u = 0x75, + v = 0x76, + w = 0x77, + x = 0x78, + y = 0x79, + z = 0x7a, + + A = 0x41, + B = 0x42, + C = 0x43, + D = 0x44, + E = 0x45, + F = 0x46, + G = 0x47, + H = 0x48, + I = 0x49, + J = 0x4a, + K = 0x4b, + L = 0x4c, + M = 0x4d, + N = 0x4e, + O = 0x4f, + P = 0x50, + Q = 0x51, + R = 0x52, + S = 0x53, + T = 0x54, + U = 0x55, + V = 0x56, + W = 0x57, + X = 0x58, + Y = 0x59, + Z = 0x5a, + + ampersand = 0x26, // & + asterisk = 0x2a, // * + at = 0x40, // @ + backslash = 0x5c, // \ + backtick = 0x60, // ` + bar = 0x7c, // | + caret = 0x5e, // ^ + closeBrace = 0x7d, // } + closeBracket = 0x5d, // ] + closeParen = 0x29, // ) + colon = 0x3a, // : + comma = 0x2c, // , + dot = 0x2e, // . + doubleQuote = 0x22, // " + equals = 0x3d, // = + exclamation = 0x21, // ! + greaterThan = 0x3e, // > + hash = 0x23, // # + lessThan = 0x3c, // < + minus = 0x2d, // - + openBrace = 0x7b, // { + openBracket = 0x5b, // [ + openParen = 0x28, // ( + percent = 0x25, // % + plus = 0x2b, // + + question = 0x3f, // ? + semicolon = 0x3b, // ; + singleQuote = 0x27, // ' + slash = 0x2f, // / + tilde = 0x7e, // ~ + + backspace = 0x08, // \b + formFeed = 0x0c, // \f + byteOrderMark = 0xfeff, + tab = 0x09, // \t + verticalTab = 0x0b, // \v +} diff --git a/packages/typespec-vscode/src/task-provider.ts b/packages/typespec-vscode/src/task-provider.ts index 08cd0a8355..39d8da558e 100644 --- a/packages/typespec-vscode/src/task-provider.ts +++ b/packages/typespec-vscode/src/task-provider.ts @@ -2,8 +2,8 @@ import { resolve } from "path"; import vscode, { workspace } from "vscode"; import { Executable } from "vscode-languageclient/node.js"; import logger from "./log/logger.js"; +import { normalizeSlashes } from "./path-utils.js"; import { resolveTypeSpecCli } from "./tsp-executable-resolver.js"; -import { normalizeSlash } from "./utils.js"; import { VSCodeVariableResolver } from "./vscode-variable-resolver.js"; export function createTaskProvider() { @@ -15,7 +15,7 @@ export function createTaskProvider() { .then((uris) => uris .filter((uri) => uri.scheme === "file" && !uri.fsPath.includes("node_modules")) - .map((uri) => normalizeSlash(uri.fsPath)), + .map((uri) => normalizeSlashes(uri.fsPath)), ); logger.info(`Found ${targetPathes.length} main.tsp files`); const tasks: vscode.Task[] = []; @@ -56,7 +56,7 @@ function getTaskPath(targetPath: string): { absoluteTargetPath: string; workspac }); targetPath = variableResolver.resolve(targetPath); targetPath = resolve(workspaceFolder, targetPath); - targetPath = normalizeSlash(variableResolver.resolve(targetPath)); + targetPath = normalizeSlashes(variableResolver.resolve(targetPath)); return { absoluteTargetPath: targetPath, workspaceFolder }; } diff --git a/packages/typespec-vscode/src/tsp-executable-resolver.ts b/packages/typespec-vscode/src/tsp-executable-resolver.ts index ca61f89e30..934f3b8007 100644 --- a/packages/typespec-vscode/src/tsp-executable-resolver.ts +++ b/packages/typespec-vscode/src/tsp-executable-resolver.ts @@ -1,8 +1,8 @@ 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 { SettingName } from "./types.js"; import { isFile, loadModule, useShellInExec } from "./utils.js"; import { VSCodeVariableResolver } from "./vscode-variable-resolver.js"; diff --git a/packages/typespec-vscode/src/tsp-language-client.ts b/packages/typespec-vscode/src/tsp-language-client.ts index c3b9ec840c..f0d6c6f571 100644 --- a/packages/typespec-vscode/src/tsp-language-client.ts +++ b/packages/typespec-vscode/src/tsp-language-client.ts @@ -1,8 +1,20 @@ +import type { + CustomRequestName, + InitProjectConfig, + InitProjectContext, + InitProjectTemplate, + ServerInitializeResult, +} from "@typespec/compiler"; 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"; +import { + ExecOutput, + isWhitespaceStringOrUndefined, + listParentFolder, + spawnExecutionAndLogToOutput, +} from "./utils.js"; export class TspLanguageClient { constructor( @@ -10,6 +22,95 @@ export class TspLanguageClient { private exe: Executable, ) {} + private initProjectContext?: InitProjectContext; + + get state() { + return this.client.state; + } + + get initializeResult(): ServerInitializeResult | undefined { + return this.client.initializeResult as ServerInitializeResult; + } + + async getInitProjectContext(): Promise { + if (this.initProjectContext) { + return this.initProjectContext; + } + + if (this.initializeResult?.customCapacities?.getInitProjectContext !== true) { + logger.warning( + "Get init project context is not supported by the current TypeSpec Compiler's LSP.", + ); + return undefined; + } + const getInitProjectContextRequestName: CustomRequestName = "typespec/getInitProjectContext"; + try { + this.initProjectContext = await this.client.sendRequest(getInitProjectContextRequestName); + return this.initProjectContext; + } catch (e) { + logger.error("Unexpected error when getting init project context", [e]); + return undefined; + } + } + + async validateInitProjectTemplate(template: InitProjectTemplate): Promise { + if (this.initializeResult?.customCapacities?.validateInitProjectTemplate !== true) { + logger.warning( + "Validate init project template is not supported by the current TypeSpec Compiler's LSP.", + ); + return false; + } + const validateInitProjectTemplateRequestName: CustomRequestName = + "typespec/validateInitProjectTemplate"; + try { + return await this.client.sendRequest(validateInitProjectTemplateRequestName, { template }); + } catch (e) { + logger.error("Unexpected error when validating init project template", [e]); + return false; + } + } + + async initProject(config: InitProjectConfig): Promise { + if (this.initializeResult?.customCapacities?.initProject !== true) { + logger.warning("Init project is not supported by the current TypeSpec Compiler's LSP."); + return false; + } + const initProjectRequestName: CustomRequestName = "typespec/initProject"; + try { + const result = await this.client.sendRequest(initProjectRequestName, { config }); + return result === true; + } catch (e) { + logger.error("Unexpected error when initializing project", [e]); + return false; + } + } + + async runCliCommand(args: string[], cwd: string): Promise { + if (isWhitespaceStringOrUndefined(this.initializeResult?.compilerCliJsPath)) { + logger.warning( + `Failed to run cli command with args [${args.join(", ")}] because no compilerCliJsPath is provided by the server. Please consider upgrade TypeSpec Compiler.`, + ); + return undefined; + } + try { + const result = await spawnExecutionAndLogToOutput( + "node", + [this.initializeResult!.compilerCliJsPath!, ...args], + cwd, + ); + if (result.exitCode !== 0) { + logger.error( + `Cli command with args [${args.join(", ")}] finishes with non-zero exit code ${result.exitCode}. Please check previous log for details`, + result.error ? [result.error] : [], + ); + } + return result; + } catch (e) { + logger.error(`Unexpected error when running Cli command with args [${args.join(", ")}]`, [e]); + return undefined; + } + } + async restart(): Promise { try { if (this.client.needsStop()) { @@ -41,7 +142,7 @@ export class TspLanguageClient { } } - async start(): Promise { + async start(showPopupWhenError: boolean): Promise { try { if (this.client.needsStart()) { await this.client.start(); @@ -61,13 +162,13 @@ export class TspLanguageClient { " - TypeSpec server path is configured with https://github.com/microsoft/typespec#installing-vs-code-extension.", ].join("\n"), [], - { showOutput: false, showPopup: true }, + { showOutput: false, showPopup: showPopupWhenError }, ); logger.error("Error detail", [e]); } else { logger.error("Unexpected error when starting TypeSpec server", [e], { showOutput: false, - showPopup: true, + showPopup: showPopupWhenError, }); } } diff --git a/packages/typespec-vscode/src/types.ts b/packages/typespec-vscode/src/types.ts new file mode 100644 index 0000000000..c21dba7e6b --- /dev/null +++ b/packages/typespec-vscode/src/types.ts @@ -0,0 +1,30 @@ +export const enum SettingName { + TspServerPath = "typespec.tsp-server.path", + InitTemplatesUrls = "typespec.initTemplatesUrls", +} + +export const enum CommandName { + ShowOutputChannel = "typespec.showOutputChannel", + RestartServer = "typespec.restartServer", + InstallGlobalCompilerCli = "typespec.installGlobalCompilerCli", + CreateProject = "typespec.createProject", + OpenUrl = "typespec.openUrl", +} + +export interface InstallGlobalCliCommandArgs { + /** + * whether to confirm with end user before action + * default: false + */ + confirm: boolean; + confirmTitle?: string; + confirmPlaceholder?: string; +} + +export interface RestartServerCommandArgs { + /** + * whether to recreate TspLanguageClient instead of just restarting it + */ + forceRecreate: boolean; + popupRecreateLspError: boolean; +} diff --git a/packages/typespec-vscode/src/utils.ts b/packages/typespec-vscode/src/utils.ts index 1bbed0a3ff..f5b72bae7d 100644 --- a/packages/typespec-vscode/src/utils.ts +++ b/packages/typespec-vscode/src/utils.ts @@ -1,20 +1,11 @@ import type { ModuleResolutionResult, ResolveModuleHost } from "@typespec/compiler"; +import { spawn, SpawnOptions } from "child_process"; import { readFile, realpath, stat } from "fs/promises"; -import { dirname, normalize, resolve } from "path"; +import { dirname } from "path"; +import { CancellationToken } from "vscode"; 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; -} +import { isUrl } from "./path-utils.js"; export async function isFile(path: string) { try { @@ -102,3 +93,211 @@ export async function loadModule( return undefined; } } + +export function tryParseJson(str: string): any | undefined { + try { + return JSON.parse(str); + } catch { + return undefined; + } +} + +export async function tryReadFileOrUrl( + pathOrUrl: string, +): Promise<{ content: string; url: string } | undefined> { + if (isUrl(pathOrUrl)) { + const result = await tryReadUrl(pathOrUrl); + return result; + } else { + const result = await tryReadFile(pathOrUrl); + return result ? { content: result, url: pathOrUrl } : undefined; + } +} + +export async function tryReadFile(path: string): Promise { + try { + const content = await readFile(path, "utf-8"); + return content; + } catch (e) { + logger.debug(`Failed to read file: ${path}`, [e]); + return undefined; + } +} + +export async function tryReadUrl( + url: string, +): Promise<{ content: string; url: string } | undefined> { + try { + const response = await fetch(url, { redirect: "follow" }); + const content = await response.text(); + return { content, url: response.url }; + } catch (e) { + logger.debug(`Failed to fetch from url: ${url}`, [e]); + return undefined; + } +} + +export interface ExecOutput { + stdout: string; + stderr: string; + exitCode: number; + error: any; + spawnOptions: SpawnOptions; +} +export interface spawnExecutionEvents { + onStdioOut?: (data: string) => void; + onStdioError?: (error: string) => void; + onError?: (error: any, stdout: string, stderr: string) => void; + onExit?: (code: number | null, stdout: string, stderror: string) => void; +} + +/** + * The promise will be rejected if the process exits with non-zero code or error occurs. Please make sure the rejection is handled property with try-catch + * + * @param exe + * @param args + * @param cwd + * @returns + */ +export function spawnExecutionAndLogToOutput( + exe: string, + args: string[], + cwd: string, +): Promise { + return spawnExecution(exe, args, cwd, { + onStdioOut: (data) => { + logger.info(data.trim()); + }, + onStdioError: (error) => { + logger.error(error.trim()); + }, + onError: (error) => { + if (error?.code === "ENOENT") { + logger.error(`Cannot find ${exe} executable. Make sure it can be found in your path.`); + } + }, + }); +} + +/** + * The promise will be rejected if the process exits with non-zero code or error occurs. Please make sure the rejection is handled property with try-catch + * + * @param exe + * @param args + * @param cwd + * @param on + * @returns + */ +export function spawnExecution( + exe: string, + args: string[], + cwd: string, + on?: spawnExecutionEvents, +): Promise { + const shell = process.platform === "win32"; + const cmd = shell && exe.includes(" ") ? `"${exe}"` : exe; + let stdout = ""; + let stderr = ""; + + const options: SpawnOptions = { + shell, + stdio: "pipe", + windowsHide: true, + cwd, + }; + const child = spawn(cmd, args, options); + + child.stdout!.on("data", (data) => { + stdout += data.toString(); + if (on && on.onStdioOut) { + try { + on.onStdioOut!(data.toString()); + } catch (e) { + logger.error("Unexpected error in onStdioOut", [e]); + } + } + }); + child.stderr!.on("data", (data) => { + stderr += data.toString(); + if (on && on.onStdioError) { + try { + on.onStdioError!(data.toString()); + } catch (e) { + logger.error("Unexpected error in onStdioError", [e]); + } + } + }); + if (on && on.onError) { + child.on("error", (error: any) => { + try { + on.onError!(error, stdout, stderr); + } catch (e) { + logger.error("Unexpected error in onError", [e]); + } + }); + } + if (on && on.onExit) { + child.on("exit", (code) => { + try { + on.onExit!(code, stdout, stderr); + } catch (e) { + logger.error("Unexpected error in onExit", [e]); + } + }); + } + return new Promise((res, rej) => { + child.on("error", (error: any) => { + rej({ + stdout, + stderr, + exitCode: -1, + error: error, + spawnOptions: options, + }); + }); + child.on("exit", (exitCode) => { + if (exitCode === 0 || exitCode === null) { + res({ + stdout, + stderr, + exitCode: exitCode ?? 0, + error: "", + spawnOptions: options, + }); + } else { + rej({ + stdout, + stderr, + exitCode: exitCode, + error: `${exe} ${args.join(" ")} failed with exit code ${exitCode}`, + spawnOptions: options, + }); + } + }); + }); +} + +/** + * if the operation is cancelled, the promise will be rejected with reason==="cancelled" + * if the operation is timeout, the promise will be rejected with reason==="timeout" + * + * @param action + * @param token + * @param timeoutInMs + * @returns + */ +export function createPromiseWithCancelAndTimeout( + action: Promise, + token: CancellationToken, + timeoutInMs: number, +) { + return new Promise((resolve, reject) => { + token.onCancellationRequested(() => { + reject("cancelled"); + }); + setTimeout(() => { + reject("timeout"); + }, timeoutInMs); + action.then(resolve, reject); + }); +} diff --git a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts new file mode 100644 index 0000000000..30ecdce49c --- /dev/null +++ b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts @@ -0,0 +1,664 @@ +import type { + InitProjectConfig, + InitProjectTemplate, + InitProjectTemplateLibrarySpec, +} from "@typespec/compiler"; +import { TIMEOUT } from "dns"; +import { readdir } from "fs/promises"; +import * as semver from "semver"; +import vscode, { OpenDialogOptions, QuickPickItem } from "vscode"; +import { State } from "vscode-languageclient"; +import logger from "../log/logger.js"; +import { getBaseFileName, getDirectoryPath, joinPaths } from "../path-utils.js"; +import { TspLanguageClient } from "../tsp-language-client.js"; +import { + CommandName, + InstallGlobalCliCommandArgs, + RestartServerCommandArgs, + SettingName, +} from "../types.js"; +import { + createPromiseWithCancelAndTimeout, + ExecOutput, + isFile, + isWhitespaceStringOrUndefined, + tryParseJson, + tryReadFileOrUrl, +} from "../utils.js"; + +type InitTemplatesUrlSetting = { + name: string; + url: string; +}; + +type InitTemplateInfo = { + source: string; + sourceType: "compiler" | "config"; + baseUrl: string; + name: string; + template: InitProjectTemplate; +}; + +interface TemplateQuickPickItem extends QuickPickItem { + info?: InitTemplateInfo; +} + +interface LibraryQuickPickItem extends QuickPickItem { + name: string; + version?: string; +} + +const COMPILER_CORE_TEMPLATES = "compiler-core-templates"; +export async function createTypeSpecProject(client: TspLanguageClient | undefined) { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + cancellable: false, + title: "Creating TypeSpec Project...", + }, + async () => { + const selectedRootFolder = await selectProjectRootFolder(); + if (!selectedRootFolder) { + logger.info("Creating TypeSpec Project cancelled when selecting project root folder."); + return; + } + if (!(await checkProjectRootFolderEmpty(selectedRootFolder))) { + logger.info( + "Creating TypeSpec Project cancelled when checking whether the project root folder is empty.", + ); + return; + } + const folderName = getBaseFileName(selectedRootFolder); + + if (!client || client.state !== State.Running) { + const r = await InstallCompilerAndRestartLSPClient(); + if (r === undefined) { + logger.info("Creating TypeSpec Project cancelled when installing Compiler/CLI"); + return; + } else { + client = r; + } + } + + const isSupport = await isCompilerSupport(client); + if (!isSupport) { + logger.info("Creating TypeSpec Project cancelled due to unsupported by compiler."); + return; + } + + const templateInfoMap = await loadInitTemplates(client); + if (templateInfoMap.size === 0) { + logger.error( + "Unexpected Error: No templates loaded. Please check the configuration of InitTemplatesUrls or upgrade @typespec/compiler and try again.", + [], + { + showOutput: true, + showPopup: true, + }, + ); + return; + } + const info = await selectTemplate(templateInfoMap); + if (info === undefined) { + logger.info("Creating TypeSpec Project cancelled when selecting template."); + return; + } else { + logger.info(`Selected template: ${info.source}.${info.name}`); + } + + const validateResult = await validateTemplate(info, client); + if (!validateResult) { + logger.info("Creating TypeSpec Project cancelled when validating template."); + return; + } + + const projectName = await vscode.window.showInputBox({ + prompt: "Please input the project name", + value: folderName, + ignoreFocusOut: true, + validateInput: (value) => { + if (isWhitespaceStringOrUndefined(value)) { + return "Project name cannot be empty."; + } + // we don't have a full rule for project name. Just have a simple check to avoid some strange name. + const regex = /^(?![./])(?!.*[./]{2})[a-zA-Z0-9-~_@./]*[a-zA-Z0-9-~_@]$/; + if (!regex.test(value)) { + return "Invalid project name. Only [a-zA-Z0-9-~_@./] are allowed and cannot start/end with [./] or consecutive [./]"; + } + return undefined; + }, + }); + if (isWhitespaceStringOrUndefined(projectName)) { + logger.info("Creating TypeSpec Project cancelled when input project name.", [], { + showOutput: false, + showPopup: false, + }); + return; + } + + const includeGitignoreResult = await vscode.window.showQuickPick(["Yes", "No"], { + title: "Do you want to generate a .gitignore file", + canPickMany: false, + placeHolder: "Do you want to generate a .gitignore file", + ignoreFocusOut: true, + }); + if (includeGitignoreResult === undefined) { + logger.info( + "Creating TypeSpec Project cancelled when selecting whether to include .gitignore.", + ); + return; + } + const includeGitignore = includeGitignoreResult === "Yes"; + + const librariesToInclude = await selectLibraries(info); + if (librariesToInclude === undefined) { + logger.info("Creating TypeSpec Project cancelled when selecting libraries to include."); + return; + } + + const inputs = await setInputs(info); + if (inputs === undefined) { + logger.info("Creating TypeSpec Project cancelled when setting inputs."); + return; + } + + const initTemplateConfig: InitProjectConfig = { + template: info.template!, + directory: selectedRootFolder, + folderName: folderName, + baseUri: info.baseUrl, + name: projectName!, + parameters: inputs ?? {}, + includeGitignore: includeGitignore, + libraries: librariesToInclude, + }; + const initResult = await initProject(client, initTemplateConfig); + if (!initResult) { + logger.info("Creating TypeSpec Project cancelled when initializing project.", [], { + showOutput: false, + showPopup: false, + }); + return; + } + + const packageJsonPath = joinPaths(selectedRootFolder, "package.json"); + if (!(await isFile(packageJsonPath))) { + logger.warning("Skip tsp install since no package.json is found in the project folder."); + } else { + // just ignore the result from tsp install. We will open the project folder anyway. + await tspInstall(client, selectedRootFolder); + } + vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(selectedRootFolder), { + forceNewWindow: false, + forceReuseWindow: true, + noRecentEntry: false, + }); + return; + }, + ); +} + +async function tspInstall( + client: TspLanguageClient, + directory: string, +): Promise { + logger.info("Installing TypeSpec project dependencies by 'tsp install'..."); + return await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Installing TypeSpec project dependencies by 'tsp install'...", + cancellable: true, + }, + async (_progress, token) => { + const TIMEOUT = 600000; // set timeout to 10 minutes which should be enough for tsp install for a new project + try { + const result = await createPromiseWithCancelAndTimeout( + client.runCliCommand(["install"], directory), + token, + TIMEOUT, + ); + return result; + } catch (e) { + if (e === "cancelled") { + logger.info( + "Installation of TypeSpec project dependencies by 'tsp install' is cancelled by user", + ); + return undefined; + } else if (e === "timeout") { + logger.error( + `Installation of TypeSpec project dependencies by 'tsp install' is timeout after ${TIMEOUT}ms`, + ); + return undefined; + } else { + logger.error( + "Unexpected error when installing TypeSpec project dependencies by 'tsp install'", + [e], + ); + return undefined; + } + } + }, + ); +} + +async function initProject( + client: TspLanguageClient, + initTemplateConfig: InitProjectConfig, +): Promise { + return await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Creating TypeSpec project...", + cancellable: true, + }, + async (_progress, token) => { + try { + const TIMEOUT = 300000; // set timeout to 5 minutes which should be enough for init project + const result = await createPromiseWithCancelAndTimeout( + client.initProject(initTemplateConfig), + token, + TIMEOUT, + ); + if (!result) { + logger.error( + "Failed to create TypeSpec project. Please check the previous log for details.", + [], + { + showOutput: true, + showPopup: true, + }, + ); + return false; + } + logger.info("Creating TypeSpec project completed. "); + return true; + } catch (e) { + if (e === "cancelled") { + logger.info("Creating TypeSpec project cancelled by user."); + } else if (e === "timeout") { + logger.error(`Creating TypeSpec project timed out (${TIMEOUT}ms).`); + } else { + logger.error("Error when creating TypeSpec project", [e], { + showOutput: true, + showPopup: true, + }); + } + return false; + } + }, + ); +} + +async function validateTemplate( + info: InitTemplateInfo, + client: TspLanguageClient, +): Promise { + if (info.sourceType === "compiler") { + // no need to validate template from compiler + return true; + } + + const compilerVersion = client.initializeResult?.serverInfo?.version; + const templateRequiredVersion = info.template.compilerVersion; + if ( + compilerVersion && + templateRequiredVersion && + semver.lt(compilerVersion, templateRequiredVersion) + ) { + logger.warning( + `The selected template is designed for tsp version ${templateRequiredVersion}, but currently using tsp version is ${compilerVersion}.`, + ); + const cont = await vscode.window.showQuickPick(["Yes", "No"], { + canPickMany: false, + placeHolder: + `Current tsp version (${compilerVersion}) < template designed tsp version(${templateRequiredVersion}). ` + + `The project created may not be correct. Do you want to continue?`, + ignoreFocusOut: true, + title: "Template version mismatches with tsp. Do you want to continue?", + }); + if (cont !== "Yes") { + logger.info( + "User confirmed/cancelled creating TypeSpec Project due to template version mismatch.", + ); + return false; + } + } + + const validateResult = await client.validateInitProjectTemplate(info.template); + if (!validateResult) { + logger.warning("Template validation failed. Please check the previous log for details.", [], { + showOutput: true, + showPopup: true, + }); + const cont = await vscode.window.showQuickPick(["Yes", "No"], { + canPickMany: false, + placeHolder: + "Template validation failed. Do you want to continue? Detail log can be found in the Output window.", + ignoreFocusOut: true, + title: "Template validation failed. Do you want to continue?", + }); + if (cont !== "Yes") { + logger.info("Creating TypeSpec Project cancelled due to template validation failure."); + return false; + } + } + return true; +} + +async function setInputs(info: InitTemplateInfo): Promise | undefined> { + const inputs: Record = {}; + for (const [key, input] of Object.entries(info.template?.inputs ?? {})) { + switch (input.type) { + case "text": + const textInput = await vscode.window.showInputBox({ + prompt: input.description, + value: input.initialValue, + ignoreFocusOut: true, + }); + if (textInput === undefined) { + logger.info(`No input provided for ${key}.`); + return undefined; + } + inputs[key] = textInput; + break; + default: + logger.error( + `Input type ${input.type} in the template is not supported. Please upgrade the extension and try again.`, + [], + { + showOutput: true, + showPopup: true, + }, + ); + return undefined; + } + } + return inputs; +} + +async function selectLibraries( + info: InitTemplateInfo, +): Promise { + const libs: LibraryQuickPickItem[] = + info.template.libraries?.map((x): LibraryQuickPickItem => { + if (typeof x === "string") { + return { + label: x, + kind: vscode.QuickPickItemKind.Default, + description: undefined, + name: x, + version: undefined, + picked: true, + }; + } + return { + label: x.name, + kind: vscode.QuickPickItemKind.Default, + description: x.version ? `(ver: ${x.version})` : undefined, + name: x.name, + version: x.version, + picked: true, + }; + }) ?? []; + if (libs.length === 0) return []; + const librariesToUpgrade = await vscode.window.showQuickPick(libs, { + title: "Please select libraries to include", + canPickMany: true, + placeHolder: "Please select libraries to include", + ignoreFocusOut: true, + }); + return librariesToUpgrade?.map((x) => ({ name: x.name, version: x.version })); +} + +async function selectTemplate( + templateInfoMap: Map, +): Promise { + const templatePickupItems: TemplateQuickPickItem[] = []; + const toPickupItems = (x: InitTemplateInfo): TemplateQuickPickItem => { + const label = + (x.template.title ?? x.name) + + ` (min compiler ver: ${x.template.compilerVersion ? x.template.compilerVersion : "-not specified-"})`; + return { + label, + detail: x.template.description, + kind: vscode.QuickPickItemKind.Default, + info: x, + }; + }; + // Templates from compiler should always be on the top + templateInfoMap.get(COMPILER_CORE_TEMPLATES)?.forEach((x) => { + templatePickupItems.push(toPickupItems(x)); + }); + for (const key of templateInfoMap.keys()) { + if (key === COMPILER_CORE_TEMPLATES) { + continue; + } + const temps = []; + for (const info of templateInfoMap.get(key) ?? []) { + if (!info || !info.template) { + logger.warning(`Template ${info.name} in ${key} is empty. Skip it.`); + continue; + } + temps.push(toPickupItems(info)); + } + if (temps.length > 0) { + templatePickupItems.push({ + label: key, + kind: vscode.QuickPickItemKind.Separator, + }); + templatePickupItems.push(...temps); + } + } + templatePickupItems.push({ + label: "Settings", + kind: vscode.QuickPickItemKind.Separator, + info: undefined, + }); + const configureSettingsItem: TemplateQuickPickItem = { + label: "Configure TypeSpec Project Templates", + kind: vscode.QuickPickItemKind.Default, + info: undefined, + buttons: [ + { + iconPath: new vscode.ThemeIcon("settings-gear"), + tooltip: "Configure TypeSpec Project Templates", + }, + ], + }; + templatePickupItems.push(configureSettingsItem); + const quickPickup = vscode.window.createQuickPick(); + quickPickup.items = templatePickupItems; + quickPickup.canSelectMany = false; + quickPickup.ignoreFocusOut = true; + quickPickup.title = "Please select a template"; + quickPickup.placeholder = "Please select a template"; + const gotoConfigSettings = () => { + logger.info("User select to open settings to configure TypeSpec Project Templates"); + quickPickup.hide(); + vscode.commands.executeCommand("workbench.action.openSettings", SettingName.InitTemplatesUrls); + }; + quickPickup.onDidTriggerItemButton((event) => { + if (event.item === configureSettingsItem) { + gotoConfigSettings(); + } + }); + const selectionPromise = new Promise((resolve) => { + quickPickup.onDidAccept(() => { + const selectedItem = quickPickup.selectedItems[0]; + resolve(selectedItem); + quickPickup.hide(); + }); + quickPickup.onDidHide(() => { + resolve(undefined); + quickPickup.dispose(); + }); + }); + quickPickup.show(); + + const selected = await selectionPromise; + if (configureSettingsItem === selected) { + gotoConfigSettings(); + return undefined; + } + return selected?.info; +} + +async function isCompilerSupport(client: TspLanguageClient): Promise { + if ( + client.initializeResult?.serverInfo?.version === undefined || + client.initializeResult?.customCapacities?.getInitProjectContext !== true || + client.initializeResult?.customCapacities?.validateInitProjectTemplate !== true || + client.initializeResult?.customCapacities?.initProject !== true + ) { + logger.error( + `Create project feature is not supported by the current TypeSpec Compiler (ver ${client.initializeResult?.serverInfo?.version ?? "<= 0.63.0"}). Please upgrade TypeSpec Compiler and try again.`, + [], + { + showOutput: true, + showPopup: true, + }, + ); + return false; + } + return true; +} + +async function loadInitTemplates( + client: TspLanguageClient, +): Promise> { + logger.info("Loading init templates from compiler..."); + const templateInfoMap: Map = new Map(); + const ipContext = await client.getInitProjectContext(); + if ( + ipContext?.coreInitTemplates && + ipContext?.coreInitTemplates.templates && + Object.entries(ipContext?.coreInitTemplates.templates).length > 0 + ) { + templateInfoMap.set( + COMPILER_CORE_TEMPLATES, + Object.entries(ipContext.coreInitTemplates.templates) + .filter(([_key, value]) => value !== undefined) + .map(([key, value]) => ({ + source: COMPILER_CORE_TEMPLATES, + sourceType: "compiler", + baseUrl: ipContext.coreInitTemplates.baseUri, + name: key, + template: value, + })), + ); + } + logger.info("Loading init templates from config..."); + const settings = vscode.workspace + .getConfiguration() + .get(SettingName.InitTemplatesUrls); + if (settings) { + for (const item of settings) { + const { content, url } = (await tryReadFileOrUrl(item.url)) ?? { + content: undefined, + url: item.url, + }; + if (!content) { + logger.error(`Failed to read template from ${item.url}. The url will be skipped`, [], { + showOutput: true, + showPopup: false, + }); + continue; + } else { + const json = tryParseJson(content); + if (!json) { + logger.error( + `Failed to parse templates content from ${item.url}. The url will be skipped`, + [], + { showOutput: true, showPopup: false }, + ); + continue; + } else { + for (const [key, value] of Object.entries(json)) { + if (value !== undefined) { + const info: InitTemplateInfo = { + source: item.name, + sourceType: "config", + baseUrl: getDirectoryPath(url), + name: key, + template: value as InitProjectTemplate, + }; + templateInfoMap.get(item.name)?.push(info) ?? templateInfoMap.set(item.name, [info]); + } + } + } + } + } + } + logger.info(`${templateInfoMap.size} templates loaded.`); + return templateInfoMap; +} + +async function selectProjectRootFolder(): Promise { + logger.info("Selecting project root folder..."); + const folderOptions: OpenDialogOptions = { + canSelectMany: false, + openLabel: "Select Folder", + canSelectFolders: true, + canSelectFiles: false, + title: "Select project root folder", + }; + + const folderUri = await vscode.window.showOpenDialog(folderOptions); + if (!folderUri || folderUri.length === 0) { + return undefined; + } + const selectedFolder = folderUri[0].fsPath; + logger.info(`Selected root folder: ${selectedFolder}`); + return selectedFolder; +} + +async function checkProjectRootFolderEmpty(selectedFolder: string): Promise { + try { + const files = await readdir(selectedFolder); + if (files.length > 0) { + const cont = await vscode.window.showQuickPick(["Yes", "No"], { + canPickMany: false, + placeHolder: "The folder to create project is not empty. Do you want to continue?", + ignoreFocusOut: true, + title: "The folder to create project is not empty. Do you want to continue?", + }); + if (cont !== "Yes") { + logger.info("Selected folder is not empty and user confirmed not to continue."); + return false; + } + } + return true; + } catch (e) { + logger.error("Error when checking whether selected folder is empty", [e], { + showOutput: true, + showPopup: true, + }); + return false; + } +} + +async function InstallCompilerAndRestartLSPClient(): Promise { + const igcArgs: InstallGlobalCliCommandArgs = { + confirm: true, + confirmTitle: "No TypeSpec Compiler/CLI found which is needed to create TypeSpec project.", + confirmPlaceholder: + "No TypeSpec Compiler/CLI found which is needed to create TypeSpec project.", + }; + const result = await vscode.commands.executeCommand( + CommandName.InstallGlobalCompilerCli, + igcArgs, + ); + if (!result) { + return undefined; + } + logger.info("Try to restart lsp client after installing compiler."); + const rsArgs: RestartServerCommandArgs = { + forceRecreate: false, + popupRecreateLspError: true, + }; + const newClient = await vscode.commands.executeCommand( + CommandName.RestartServer, + rsArgs, + ); + return newClient; +} diff --git a/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts b/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts new file mode 100644 index 0000000000..d22b3a1182 --- /dev/null +++ b/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts @@ -0,0 +1,77 @@ +import vscode, { QuickPickItem } from "vscode"; +import logger from "../log/logger.js"; +import { InstallGlobalCliCommandArgs } from "../types.js"; +import { createPromiseWithCancelAndTimeout, spawnExecutionAndLogToOutput } from "../utils.js"; + +const COMPILER_REQUIREMENT = + "Minimum Requirements: 'Node.js 20 LTS' && 'npm avaliable in command prompt'"; + +export async function installCompilerGlobally( + args: InstallGlobalCliCommandArgs | undefined, +): Promise { + // confirm with end user by default + if (args?.confirm !== false) { + const yes: QuickPickItem = { + label: "Install TypeSpec Compiler/CLI globally", + detail: COMPILER_REQUIREMENT, + description: " by 'npm install -g @typespec/compiler'", + }; + const no: QuickPickItem = { label: "Cancel" }; + const title = args?.confirmTitle ?? "Please check the requirements and confirm..."; + const confirm = await vscode.window.showQuickPick([yes, no], { + title, + placeHolder: args?.confirmPlaceholder ?? title, + }); + if (confirm !== yes) { + logger.info("User cancelled the installation of TypeSpec Compiler/CLI"); + return false; + } else { + logger.info("User confirmed the installation of TypeSpec Compiler/CLI"); + } + } else { + logger.info("Installing TypeSpec Compiler/CLI with confirmation disabled explicitly..."); + } + return await vscode.window.withProgress( + { + title: "Installing TypeSpec Compiler/CLI...", + location: vscode.ProgressLocation.Notification, + cancellable: true, + }, + async (_progress, token) => { + const TIMEOUT = 300000; // set timeout to 5 minutes which should be enough for installing compiler + try { + const output = await createPromiseWithCancelAndTimeout( + spawnExecutionAndLogToOutput( + "npm", + ["install", "-g", "@typespec/compiler"], + process.cwd(), + ), + token, + TIMEOUT, + ); + if (output.exitCode !== 0) { + logger.error( + "Failed to install TypeSpec CLI. Please check the previous log for details", + [output], + { showOutput: true, showPopup: true }, + ); + return false; + } else { + logger.info("TypeSpec CLI installed successfully"); + return true; + } + } catch (e) { + if (e === "cancelled") { + logger.info("Installation of TypeSpec Compiler/CLI is cancelled by user"); + return false; + } else if (e === "timeout") { + logger.error(`Installation of TypeSpec Compiler/CLI is timeout after ${TIMEOUT}ms`); + return false; + } else { + logger.error("Unexpected error when installing TypeSpec Compiler/CLI", [e]); + return false; + } + } + }, + ); +} diff --git a/packages/typespec-vscode/test/unit/extension.test.ts b/packages/typespec-vscode/test/unit/extension.test.ts index b82a72d41d..039c9507ba 100644 --- a/packages/typespec-vscode/test/unit/extension.test.ts +++ b/packages/typespec-vscode/test/unit/extension.test.ts @@ -1,4 +1,6 @@ +import { strictEqual } from "assert"; import { assert, beforeAll, describe, it } from "vitest"; +import { InitTemplateSchema } from "../../../compiler/dist/src/init/init-template.js"; import { ConsoleLogLogger } from "../../src/log/console-log-listener.js"; import logger from "../../src/log/logger.js"; @@ -12,5 +14,11 @@ describe("Hello world test", () => { assert(true, "test sample"); }); - // Add more unit test when needed + it("Check inputs type supported in InitTemplate", () => { + // Add this test to ensure we won't forget to add the support in VS/VSCode extension of typespec + // when we add more input types support in InitTemplate.inputs + const schema = InitTemplateSchema; + strictEqual(schema.properties.inputs.additionalProperties.properties.type.enum.length, 1); + strictEqual(schema.properties.inputs.additionalProperties.properties.type.enum[0], "text"); + }); });