From 944ec285accb55fc15b816df75c20aabf77c67ab Mon Sep 17 00:00:00 2001 From: Paul Jolly Date: Fri, 29 Nov 2024 10:49:36 +0000 Subject: [PATCH] extension: first cut of lsp-aware extension This gives the vscode-cue extension the basic capabilities to start an instance of 'cue lsp'. The code is heavily commented to: * caveat my poor handle on TypeScript; * explain the code structure of the extension; * capture my understanding and assumptions about the behaviour of VSCode and an extension instance in relation to a running LSP server; * state opinions/assumptions about error handling style in code. * state understanding about the best method of handling runtime errors, and how/when to report those to the user. The bulk of the changes sit within src/main.ts: * The extension creates a LanguageClient instance which is the bridge between a running 'cue lsp' instance (which it, the LanguageClient starts) and a VSCode window. * We use output channels for more clear logging of info and errors. * We establish a clearer policy on when to notify the end user of errors via showErrorMessage. * We handle runtime configuration changes of an extension instance. There are various other helper functions, types etc in support of these changes. At an extension configuration level this change: * Exposes two additional commands: start CUE LSP, stop CUE LSP; * aligns the engine version required by the extension with the npm dependency on the VSCode types; * sets an explicit activation event of 'onLanguage:cue', so an instance of the extension is created whenever a CUE file is opened. Note the comment 'Extension instances and configuration' on caveats in this space; * exposes the extension's configuration schema as a JSON Schema. A TODO captures moving this to CUE when we can (noting that we also need to code generate TypeScript ultimately, as well as runtime unifying a configuration value with some documented defaults). We also add various npm dependencies required by the code changes described above. Note that this change does not include any tests. This is intentional however undesirable. Adding end-to-end tests will follow later. For now, testing is limited to offline running through various scenarios using VSCode. The scenarios covered are documented in testing.md. Signed-off-by: Paul Jolly Change-Id: I6a1879a603ff2db693f045d26fe14018c01c6332 Reviewed-on: https://review.gerrithub.io/c/cue-lang/vscode-cue/+/1201041 Reviewed-by: Matthew Sackman TryBot-Result: CUEcueckoo --- extension/.vscodeignore | 1 + extension/extension.cue | 50 +++- extension/manifest.txt | 1 + extension/npm.cue | 5 +- extension/package-lock.json | 116 +++++++- extension/package.json | 44 ++- extension/src/extension.ts | 531 ++++++++++++++++++++++++++++++++++++ extension/src/main.ts | 228 ++++++++++++++-- extension/testing.md | 19 ++ 9 files changed, 950 insertions(+), 45 deletions(-) create mode 100644 extension/src/extension.ts create mode 100644 extension/testing.md diff --git a/extension/.vscodeignore b/extension/.vscodeignore index 7ebb85c..ca73f6e 100644 --- a/extension/.vscodeignore +++ b/extension/.vscodeignore @@ -21,6 +21,7 @@ launch.json src/test esbuild.js tsconfig.json +testing.md # Legacy files generated as part of skeleton - useful for reference only. _generated diff --git a/extension/extension.cue b/extension/extension.cue index b6b39ff..e4088c6 100644 --- a/extension/extension.cue +++ b/extension/extension.cue @@ -9,11 +9,13 @@ extension: npm: { icon: "media/white_circle_128.png" license: "MIT" publisher: "cuelangorg" - engines: vscode: ">=1.63.0" + engines: vscode: ">=\(devDependencies["@types/vscode"])" categories: [ "Programming Languages", ] - activationEvents: [] + activationEvents: [ + "onLanguage:cue", + ] main: "./dist/main.js" contributes: { languages: [{ @@ -30,10 +32,46 @@ extension: npm: { path: "./syntaxes/cue.tmLanguage.json" embeddedLanguages: "source.cue.embedded": "source.cue" }] - commands: [{ - command: "vscode-cue.welcome" - title: "CUE: Welcome" - }] + commands: [ + { + command: "vscode-cue.welcome" + title: "CUE: Welcome" + }, + { + command: "vscode-cue.startlsp" + title: "CUE: Start CUE LSP" + }, + { + command: "vscode-cue.stoplsp" + title: "CUE: Stop CUE LSP" + }, + ] + + // TODO(myitcv): maintain this schema as CUE, and export to JSON Schema + // when generating package.json when CUE can do this. Doing so will also + // require a more complete understanding of the dot-separated field names + // used below; for example, are these top-level only? + configuration: { + type: "object" + title: "CUE" + properties: { + "cue.useLanguageServer": { + type: "boolean" + default: true + description: "Enable cue lsp, the language server for CUE." + } + "cue.languageServerCommand": { + type: "array" + default: [] + description: "The command to run to launch the language server." + } + "cue.languageServerFlags": { + type: "array" + default: [] + description: "Flags like -rpc.trace and -logfile to be used while running the language server." + } + } + } } scripts: { "vscode:prepublish": "cue cmd genPackageJSON && npm run clean && npm run buildpackage" diff --git a/extension/manifest.txt b/extension/manifest.txt index 0b85db6..746b0b1 100644 --- a/extension/manifest.txt +++ b/extension/manifest.txt @@ -5,5 +5,6 @@ dist/main.js language-configuration.json media/white_circle_128.png package.json +src/extension.ts src/main.ts syntaxes/cue.tmLanguage.json diff --git a/extension/npm.cue b/extension/npm.cue index cfa1b72..768a99f 100644 --- a/extension/npm.cue +++ b/extension/npm.cue @@ -3,7 +3,7 @@ package extension extension: npm: devDependencies: { "@types/mocha": "10.0.7" "@types/node": "22.9.1" - "@types/vscode": "1.63.0" + "@types/vscode": "1.85.0" "@typescript-eslint/eslint-plugin": "7.14.1" "@typescript-eslint/parser": "7.11.0" "@vscode/test-cli": "0.0.9" @@ -13,4 +13,7 @@ extension: npm: devDependencies: { eslint: "8.57.0" "npm-run-all": "4.1.5" typescript: "5.4.5" + "@types/which": "3.0.4" + "vscode-languageclient": "9.0.1" + which: "5.0.0" } diff --git a/extension/package-lock.json b/extension/package-lock.json index c2f57d6..1db5c0a 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -11,7 +11,8 @@ "devDependencies": { "@types/mocha": "10.0.7", "@types/node": "22.9.1", - "@types/vscode": "1.63.0", + "@types/vscode": "1.85.0", + "@types/which": "3.0.4", "@typescript-eslint/eslint-plugin": "7.14.1", "@typescript-eslint/parser": "7.11.0", "@vscode/test-cli": "0.0.9", @@ -20,10 +21,12 @@ "esbuild": "0.21.5", "eslint": "8.57.0", "npm-run-all": "4.1.5", - "typescript": "5.4.5" + "typescript": "5.4.5", + "vscode-languageclient": "9.0.1", + "which": "5.0.0" }, "engines": { - "vscode": ">=1.63.0" + "vscode": ">=1.85.0" } }, "node_modules/@azure/abort-controller": { @@ -878,9 +881,16 @@ } }, "node_modules/@types/vscode": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.63.0.tgz", - "integrity": "sha512-iePu1axOi5WSThV6l2TYcciBIpAlMarjBC8H0y8L8ocsZLxh7MttzwFU3pjoItF5fRVGxHS0Hsvje9jO3yJsfw==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.85.0.tgz", + "integrity": "sha512-CF/RBon/GXwdfmnjZj0WTUMZN5H6YITOfBCP4iEZlOtVQXuzw6t7Le7+cR+7JzdMrnlm7Mfp49Oj2TuSXIWo3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/which": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", + "integrity": "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==", "dev": true, "license": "MIT" }, @@ -2381,6 +2391,22 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -7171,6 +7197,62 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageclient/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -7195,19 +7277,19 @@ } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "isexe": "^3.1.1" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/which-boxed-primitive": { @@ -7247,6 +7329,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/extension/package.json b/extension/package.json index 5377924..43eb512 100644 --- a/extension/package.json +++ b/extension/package.json @@ -8,12 +8,14 @@ "license": "MIT", "publisher": "cuelangorg", "engines": { - "vscode": ">=1.63.0" + "vscode": ">=1.85.0" }, "categories": [ "Programming Languages" ], - "activationEvents": [], + "activationEvents": [ + "onLanguage:cue" + ], "main": "./dist/main.js", "contributes": { "languages": [ @@ -43,8 +45,37 @@ { "command": "vscode-cue.welcome", "title": "CUE: Welcome" + }, + { + "command": "vscode-cue.startlsp", + "title": "CUE: Start CUE LSP" + }, + { + "command": "vscode-cue.stoplsp", + "title": "CUE: Stop CUE LSP" + } + ], + "configuration": { + "type": "object", + "title": "CUE", + "properties": { + "cue.useLanguageServer": { + "type": "boolean", + "default": true, + "description": "Enable cue lsp, the language server for CUE." + }, + "cue.languageServerCommand": { + "type": "array", + "default": [], + "description": "The command to run to launch the language server." + }, + "cue.languageServerFlags": { + "type": "array", + "default": [], + "description": "Flags like -rpc.trace and -logfile to be used while running the language server." + } } - ] + } }, "scripts": { "vscode:prepublish": "cue cmd genPackageJSON && npm run clean && npm run buildpackage", @@ -67,7 +98,7 @@ "devDependencies": { "@types/mocha": "10.0.7", "@types/node": "22.9.1", - "@types/vscode": "1.63.0", + "@types/vscode": "1.85.0", "@typescript-eslint/eslint-plugin": "7.14.1", "@typescript-eslint/parser": "7.11.0", "@vscode/test-cli": "0.0.9", @@ -76,6 +107,9 @@ "esbuild": "0.21.5", "eslint": "8.57.0", "npm-run-all": "4.1.5", - "typescript": "5.4.5" + "typescript": "5.4.5", + "@types/which": "3.0.4", + "vscode-languageclient": "9.0.1", + "which": "5.0.0" } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts new file mode 100644 index 0000000..faa14c2 --- /dev/null +++ b/extension/src/extension.ts @@ -0,0 +1,531 @@ +// Copyright 2024 The CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +import * as path from 'node:path'; +import which from 'which'; +import * as vscode from 'vscode'; +import * as lcnode from 'vscode-languageclient/node'; +import * as cp from 'node:child_process'; +import * as lc from 'vscode-languageclient'; + +let errTornDown = new Error('Extenssion instance already torn down'); + +// An instance of Extension represents the active instance (!!) of the VSCode +// extension that is this project. An instance of Extension is created when the +// extension is activated, and tearDown-ed when the extension is deactivated. +export class Extension { + // ctx is the context of the active extension instance passed at + // activate-time. + private ctx: vscode.ExtensionContext; + + // client is the active LSP client. A value of undefined for client indicates + // that no LSP client is running. + // + // It might seem a bit odd that this variable is called 'client', so some + // explanation. 'cue lsp' is the LSP server. The VSCode window (whether in + // folder or workspace mode) is ultimately the LSP client. An instance of the + // lcnode.LanguageClient is responsible for starting the LSP server ('cue + // lsp') and acting as the LSP client, translating + // requests/responses/notifications to/from the LSP server into + // events/changes etc within the VSCode instance. + // + // (We ignore for one second that an instance of 'cue lsp' can act simply as + // a forwarded to a true LSP server. That point can, and is, abstracted away: + // we can simply treat the running 'cue lsp' instance as the LSP server.) + // + // Hence, an instance of this extension is never directly responsible for + // starting the LSP server; everything happens through an instance of the + // lcnode.LanguageClient. Hence the client variable being defined represents + // our proxy for "this extension instance is connected to a CUE LSP server". + private client?: lcnode.LanguageClient; + + // clientStateChangeHandler is the event handler that is called back when the + // state of the running LSP client changes. Note, that we dispose of this + // handler before intentionally shutting down the LSP server. + private clientStateChangeHandler?: lcnode.Disposable; + + // config represents the configuration of the active vscode-cue extension + // instance. During activation of the extension instance, we register to + // receive callbacks when the configuration changes, so the config value + // remains current post activation. + // + // Most notably, this config value is the effective config, post application + // of documented defaults. As such, it is a deep copy of the configuration as + // reported by VSCode, "extended" by the default values we define within the + // extension. + private config?: CueConfiguration; + + // manualLspStop is true when cmdStopLSP has been called. It is reset to false + // only if the LSP is configured to be active and when cmdStartLSP is called. + private manualLspStop: boolean = false; + + private output: vscode.LogOutputChannel; + private lspOutput?: vscode.OutputChannel; + + // tornDown is set to true only when this Extension instance is being + // tearDown-ed. This happens when the corresponding extension instance has + // been deactivate-d. This state allows us to be defensive in callback + // methods, throwing errors in case we get callbacks after tearDown. + private tornDown: boolean = false; + + constructor( + ctx: vscode.ExtensionContext, + output: vscode.LogOutputChannel, + lspOutput: vscode.OutputChannel | undefined + ) { + this.ctx = ctx; + this.output = output; + + let configChangeListener = vscode.workspace.onDidChangeConfiguration(this.extensionConfigurationChange); + this.ctx.subscriptions.push(configChangeListener); + + this.registerCommand('vscode-cue.welcome', this.cmdWelcomeCUE); + this.registerCommand('vscode-cue.startlsp', this.cmdStartLSP); + this.registerCommand('vscode-cue.stoplsp', this.cmdStopLSP); + + // TODO(myitcv): in the early days of 'cue lsp', it might be worthwhile + // adding a command that toggles the enabled-ness of the LSP in the active + // workspace/folder, i.e. sets 'cue.useLanguageServer' in either workspace or + // folder configuration for the user, toggling any existing value, and + // starting/stopping the instance as appropriate given the resulting state. + + // Manually trigger a configuration changed event. This will ultimately + // start cue lsp if the configuration reflects that should happen. + this.extensionConfigurationChange(undefined); + } + + // tearDown is called in response to the deactivate function for the + // extension. At this point in the lifecycle of the extension, subscriptions + // via this.ctx will already have been disposed. Hence any remaining cleanup + // logic should be placed here. + tearDown = (): vscode.OutputChannel | undefined => { + if (this.tornDown) { + throw errTornDown; + } + this.tornDown = true; + return this.lspOutput; + }; + + // registerCommand is a light wrapper around the vscode API for registering a + // command but also simultaneously adding a dispose callback. + // + // TODO(myitcv): it isn't really documented anywhere, but the expected signature + // of callback is: + // + // (context?: any) => void | Thenable + // + // Where context represents any arguments passed to the command when it is executed. + // For now, we consistently use the signature: + // + // (context?: any) => Promise + // + // This forces the style of always returning a Promise. The rejection of a + // promise handles the error case. There is nothing to return/do in the case a + // command succeeds; commands are often run for their side effects. But per the + // comment on logging vs showing errors above, it often makes sense to show + // error messages to user if that error corresponds directly to the invoking of + // the command, such that the user would otherwise be left surprised if nothing + // happened because of the error. + registerCommand = (cmd: string, callback: (context?: any) => Promise) => { + if (this.tornDown) { + throw errTornDown; + } + + let disposable = vscode.commands.registerCommand(cmd, callback); + this.ctx.subscriptions.push(disposable); + }; + + // extensionConfigurationChange is the callback that fires when the extension + // instance's configuration has changed, including the initial configuration + // change that happens at activation time. + // + // s will be undefined in the case that extensionConfigurationChange is called + // during activation. + // + // Note that this function updates config with a deep copy of the configuration + // reported by VSCode (then extended with defaults). + extensionConfigurationChange = async (s: vscode.ConfigurationChangeEvent | undefined): Promise => { + if (this.tornDown) { + throw errTornDown; + } + + // For some unknown reason, we get random callbacks (during development at + // least) for configuration changes where there is no actual configuration + // change. That is to say, there is no observable configuration change when + // considering the concrete configuration, at any point in the configuration + // graph. This appears to be mitigated by calling + // s.affectsConfiguration('cue') to determine if there has been a change. We + // might need to modify this to be a comparison of the net configuration + // (post applyConfigDefaults) but for now, given the defaults are static, the + // VSCode check suffices. + if (s !== undefined && !s.affectsConfiguration('cue')) { + return Promise.resolve(); + } + + let vscodeConfig = vscode.workspace.getConfiguration('cue'); + let configCopy = JSON.parse(JSON.stringify(vscodeConfig)) as CueConfiguration; + this.config = applyConfigDefaults(configCopy); + this.output.info(`configuration updated to: ${JSON.stringify(this.config, null, 2)}`); + + let err; + if (this.config.useLanguageServer) { + // TODO: we might want to revisit just blindly restarting the LSP, for + // example in case the configuration for the LSP client or server hasn't + // changed. But for now it's good enough. + [, err] = await ve(this.startCueLsp()); + } else { + [, err] = await ve(this.stopCueLsp()); + } + if (err !== null) { + return Promise.reject(err); + } + + return Promise.resolve(); + }; + + // cmdWelcomeCUE is a basic command that can be used to verify whether the + // vscode-cue extension is loaded at all (beyond checking output logs). + cmdWelcomeCUE = async (context?: any): Promise => { + if (this.tornDown) { + throw errTornDown; + } + + vscode.window.showInformationMessage('Welcome to CUE!'); + return Promise.resolve(); + }; + + // cmdStartLSP is used to explicitly (re)start the LSP server. It can only be + // called if the extension configuration allows for it. + cmdStartLSP = async (context?: any): Promise => { + if (this.tornDown) { + throw errTornDown; + } + + if (!this.config!.useLanguageServer) { + vscode.window.showErrorMessage(`useLanguageServer is configured to false`); + return Promise.resolve(); + } + this.manualLspStop = false; + return this.startCueLsp('manually'); + }; + + // cmdStopLSP is used to explicitly stop the LSP server. + cmdStopLSP = async (context?: any): Promise => { + if (this.tornDown) { + throw errTornDown; + } + + this.manualLspStop = true; + return this.stopCueLsp('manually'); + }; + + // startCueLsp is responsible for starting 'cue lsp'. It stops an existing client + // if there is one, to prevent there being two running instances. + // + // By default, the LSP is started using a version of cmd/cue found in PATH. + // + // TODO: proper error handling strategy here. This is run async from activate + // so updating the user is "up to us". We might need to refine messages that + // are shown, log messages, etc. We will likely refine this error strategy as + // users report problems "in the wild". + startCueLsp = async (source: string = ''): Promise => { + if (this.tornDown) { + throw errTornDown; + } + + if (!this.config!.useLanguageServer || this.manualLspStop) { + // Nothing to do. Explicit attempts to start the LSP in this situation are + // handled elsewhere. And in case the user has manually stopped the LSP, + // we should only run it if explicitly asked to via a command. And if we + // had been through that path, then manualLspStop would be false. + return Promise.resolve(); + } + + if (source !== '') { + source = `${source} `; + } + + let err; + + // Stop the running instance if there is one. + [, err] = await ve(this.stopCueLsp()); + if (err !== null) { + return Promise.reject(err); + } + + // By the this stage, config represents the defaults-populated configuration + // we should use. The first element of the languageServerCommand is the + // command that should be run. We need to handle the normal cases: + // + // 1. cue - a simple relative path, no slashes + // 2. ./relative/path/to/cue - a non-simple (!) relative path, >=1 slashes + // 3. /absolute/path/to/cue - absolute filepath + // + // For now, we err on the side of caution by only allowing simple relative + // and absolute file paths. Reason being, it's not clear (yet) what this + // means in the case of the running VSCode, workspace etc. And we might want + // to support expanding special VSCode variables in the path. + + let command = this.config!.languageServerCommand[0]; + + if (!path.isAbsolute(command) && command.includes(path.sep)) { + vscode.window.showErrorMessage( + `invalid command path ${JSON.stringify(command)}; only simple relative or absolute file paths supported` + ); + return Promise.resolve(); + } + + if (!path.isAbsolute(command)) { + let resolvedCommand: string | null; + [resolvedCommand, err] = await ve(which(command)); + if (err !== null) { + vscode.window.showErrorMessage(`failed to find ${JSON.stringify(command)} in PATH: ${err}`); + return Promise.resolve(); + } + command = resolvedCommand!; + } + + // TODO(myitcv): version-related checks would go here. Run 'cue help lsp' as + // a check to ensure we have at least some LSP support for now, + // distinguishing from the case where the command is not found (which should, + // races aside, only happen in case an absolute path is specified and that + // path does not exist). + // + // Note: we do not worry about the working directory here. The command we are + // running should not care at all about the working directory. + let cueHelpLsp: Cmd = { + Args: [command, 'help', 'lsp'] + }; + [, err] = await ve(osexecRun(cueHelpLsp)); + if (err !== null) { + if (isErrnoException(err)) { + vscode.window.showErrorMessage(`failed to run ${JSON.stringify(cueHelpLsp)}: ${err}`); + return Promise.resolve(); + } + // Probably running an early version of CUE with no LSP support. + vscode.window.showErrorMessage( + `the version of cmd/cue at ${JSON.stringify(command)} does not support 'cue lsp'. Please upgrade to at least v0.11.0` + ); + return Promise.resolve(); + } + + const serverOptions: lcnode.ServerOptions = { + command: command, + args: [...this.config!.languageServerCommand.slice(1), ...this.config!.languageServerFlags] + + // NOTE: we do not set the working directory. The 'cue lsp' ignores the + // working directory and always will. It will always rely on the paths of + // WorkspaceFolders (and possibly in a fallback scenario the RootURI in the + // call to Initialize). + }; + + this.output.info(`${source}starting CUE LSP with server options: ${JSON.stringify(serverOptions, null, 2)}`); + + // Create an output channel for logging, errors etc received from 'cue lsp' + // and the LanguageClient. To include this in the extension logging would + // clutter things unncessarily. + if (this.lspOutput === undefined) { + this.lspOutput = vscode.window.createOutputChannel('CUE Language Server'); + } + + // Options to control the language client. For example, which file events + // (open, modified etc) get sent to the server. + const clientOptions: lcnode.LanguageClientOptions = { + documentSelector: [{ scheme: 'file', language: 'cue' }], + outputChannel: this.lspOutput + }; + + // Create the language client + // + // TODO: properly handle the client/server dying and informing the user, + // beyond the simple notification of state change. + this.client = new lcnode.LanguageClient('cue lsp', 'cue lsp: the CUE Language Server', serverOptions, clientOptions); + this.clientStateChangeHandler = this.client.onDidChangeState(this.cueLspStateChange); + + // Dispose of the state change handler before the client itself, + // otherwise we get a notification of a state change when we are + // disposing of the client (which only happens when the extension + // is deactivating). + this.ctx.subscriptions.push(this.clientStateChangeHandler); + + // Start the client, which in turn will start 'cue lsp' + this.client.start(); + this.ctx.subscriptions.push(this.client); + + // At this point, all events happend via callbacks in terms of state changes, + // or the client-server interaction of the LSP protocol. + + return Promise.resolve(); + }; + + // stopCueLsp kills the running LSP client, if there is one. + stopCueLsp = async (source: string = ''): Promise => { + if (this.tornDown) { + throw errTornDown; + } + + if (this.client === undefined) { + return Promise.resolve(); + } + + if (source !== '') { + source = `${source} `; + } + + this.output.info(`${source}stopping cue lsp`); + + // Stop listening to the event first so that we don't handle state changes + // in the usual way. + this.clientStateChangeHandler!.dispose(); + + let err; + // TODO: use a different timeout? + [, err] = await ve(this.client.stop()); + this.client = undefined; + this.clientStateChangeHandler = undefined; + if (err !== null) { + // TODO: we get an error message here relating to the process for stopping + // the server timing out, when providing an argument to stop(). Why? And + // if that does happen, what can we know about the state of the running + // process? + return Promise.reject(new Error(`failed to stop cue lsp: ${err}`)); + } + + return Promise.resolve(); + }; + + cueLspStateChange = (s: lc.StateChangeEvent): void => { + if (this.tornDown) { + throw errTornDown; + } + + let oldState = JSON.stringify(humanReadableState(s.oldState)); + let newState = JSON.stringify(humanReadableState(s.newState)); + this.output.info(`cue lsp client state change: from ${oldState} to ${newState}`); + }; +} + +// CueConfiguration corresponds to the type of the configuration of the vscode-cue +// extension. +// +// TODO: keep this in sync with the configuration schema in CUE. +type CueConfiguration = { + useLanguageServer: boolean; + languageServerCommand: string[]; + languageServerFlags: string[]; +}; + +// applyConfigDefaults updates c to apply defaults. Note this returns a value +// that is only a shallow copy of c. So in effect, the caller must consider +// that c is in effect mutated by a called to applyConfigDefaults, because the +// value return can, in general, cause mutations to c. +function applyConfigDefaults(c: CueConfiguration): CueConfiguration { + const defaultConfig: CueConfiguration = { + languageServerCommand: ['cue', 'lsp'], + languageServerFlags: [], + useLanguageServer: true + }; + + // TODO: switch to using CUE as the source of truth for defaults (somehow) + return { + ...defaultConfig, + ...c + }; +} + +// humanReadableState returns a human readable version of the language client +// state. +function humanReadableState(s: lcnode.State): string { + switch (s) { + case lcnode.State.Running: + return 'running'; + case lcnode.State.Stopped: + return 'stopped'; + case lcnode.State.Starting: + return 'starting'; + } +} + +// ValErr is convenience type for a nullable [value, error] pair. JavaScript +// does not have zero values and hence we are forced to create a nullable +// type. +type ValErr = [T | null, Error | null]; + +// ve converts a promise that returns a single value to a promise that returns +// a [value, error] tuple, the error being the value "caught" in case the input +// promise is rejected. When used with 'await', this allows JavaScript-native +// Promise-aware functions that otherwise encourage the use of try-catch with +// 'await' to transform results into a more Go-style of error handling. +// +// TODO: can we be smarter with the Promise case? +function ve(p: Promise): Promise> { + return p.then( + (v: T) => { + return [v, null]; + }, + (err) => { + return [null, err]; + } + ); +} + +// Type Cmd is a rip off of os/exec.Cmd. +type Cmd = { + Args: string[]; + Stdout?: string; + Stderr?: string; + Err?: cp.ExecFileException | null; +}; + +// osexecRun is a Go os/exec.Cmd.Run rip-off, to give a Go-style feel to +// running a process. +async function osexecRun(cmd: Cmd): Promise { + return new Promise((resolve, reject) => { + cp.execFile(cmd.Args[0], cmd.Args.slice(1), (err, stdout, stderr) => { + cmd.Stdout = stdout; + cmd.Stderr = stderr; + cmd.Err = err; + if (err !== null) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +// isErrnoException helps us check whether an error return from child_process' +// exec-like calls is as a result of ENOENT or not. +// +// https://stackoverflow.com/questions/51523509/in-typescript-how-do-you-make-a-distinction-between-node-and-vanilla-javascript +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return ( + isArbitraryObject(error) && + error instanceof Error && + (typeof error.errno === 'number' || typeof error.errno === 'undefined') && + (typeof error.code === 'string' || typeof error.code === 'undefined') && + (typeof error.path === 'string' || typeof error.path === 'undefined') && + (typeof error.syscall === 'string' || typeof error.syscall === 'undefined') + ); +} + +// ArbitraryObject is used as part of isErrnoException. +type ArbitraryObject = { [key: string]: unknown }; + +// isArbitraryObject is used as part of isErrnoException. +function isArbitraryObject(potentialObject: unknown): potentialObject is ArbitraryObject { + return typeof potentialObject === 'object' && potentialObject !== null; +} diff --git a/extension/src/main.ts b/extension/src/main.ts index 3a151b5..9faff9c 100644 --- a/extension/src/main.ts +++ b/extension/src/main.ts @@ -1,25 +1,211 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below +// Copyright 2024 The CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + import * as vscode from 'vscode'; -// This method is called when your extension is activated -// Your extension is activated the very first time the command is executed -export function activate(context: vscode.ExtensionContext) { - // Use the console to output diagnostic information (console.log) and errors (console.error) - // This line of code will only be executed once when your extension is activated - console.log('Congratulations, your extension "vscode-cue" is now active!'); - - // The command has been defined in the package.json file - // Now provide the implementation of the command with registerCommand - // The commandId parameter must match the command field in package.json - const disposable = vscode.commands.registerCommand('vscode-cue.welcome', () => { - // The code you place here will be executed every time your command is executed - // Display a message box to the user - vscode.window.showInformationMessage('Welcome to CUE'); - }); - - context.subscriptions.push(disposable); +import { Extension } from './extension'; + +// Note by myitcv +// ============== +// I am not good with TypeScript. I struggle with writing idiomatic code. Truth +// be told I would prefer to have written this extension in Go and use GopherJS +// to transpile to JavaScript! But that's a story for another day. Any poor +// style, etc, is my own. +// +// Structure of the extension +// ========================== +// Unlike the vscode-go project, we hold the state for an instance of the +// extension in an instance of the Extension class. We will later work out how +// to adapt/change this to support good end-to-end/integration testing. +// +// In a similar vein, we keep the number of TypeScript modules to a minimum for +// now. It is unfortunate that TypeScript does not have an analog to Go's +// packages. +// +// Extension instances and configuration +// ===================================== +// Every VSCode window is in one of three modes: +// +// 1. folder mode - when a single folder has been opened. +// 2. workspace mode - a specific .code-workspace is open. +// 3. untitled workspace mode +// +// Workspace mode supports there being multiple workspace folders open. +// +// For now, we do not support untitled workspace mode. This might change in the +// future, especially when we understand the implications for the LSP server. +// +// In either folder or workspace mode, at most a single instance of the CUE +// extension may be running. It is possible for a user to disable extensions on +// a per folder/workspace basis. +// +// TODO: better understand (and point users towards documentation on) how the +// effective extension instance configuration is resolved in workspace mode +// where there are multiple folders, especially folders with settings that +// conflict. This feels like very much an edge case, but at least for now we +// log the effective extension configuration so it will be clear what +// configuration is being used. +// +// Logging errors vs showing errors +// ================================ +// We don't have a well-established pattern in this space yet, so this comment +// is really designed to capture the high-level thinking in one place. It is +// very much open to revision: +// +// 1. If the user has invokved a command provided by this extension, and the +// error occurs during the excution of the command, use showErrorMessage. +// 2. If an error occurs during startup, use showErrorMessage. +// 3. Otherwise log errors in the output using output.error. +// +// In the case that showErrorMessage is used to "handle" the error, it is generally +// incorrect to also then reject a promise. For example, if a command handler +// deals with an error by informing the user via showErrorMessage, but it +// also then returns a rejected promise in the handler, the end user will get: +// +// * a popup error modal dialog wtih an 'ok' button directing the user to +// consult the output window. This corresponds to the rejected promise. +// * a non-modal elegant error message popup bottom right, informing them of +// the error message. +// +// We are fully in control of the style, options in the second case and not the +// first. Hence showErrorMessage should generally be accompanied by a resolved +// promise, indicating the error has been handled. +// +// Logging level +// ============= +// For now, we broadly log everything to the output channel as info-level +// logging. This is intentionally noisy for now, until we get some experience +// from users reading the logs etc. Much if not all of that logging can flip to +// trace-level logging in the future. +// +// Method declaration style +// ======================== +// The class arrow field function style of declaring methods in the Extension +// class, really because I don't know of a cleaner more idiomatic method. +// Suggestions/corrections welcomed! +// +// Code error-handling style +// ========================= +// We also look to follow a Go-style approach to errors. The situations where +// async functions are required are defined for us by the VSCode extension +// "API". Within the extension code we chose to await async functions rather +// than chaining promises. Furthermore, we use the ve() utility function to +// convert an async function that returns a Promise into a Promise<[T, +// Err]>. An await on such a function allows for Go-style error handling, +// checking whether the error value in the tuple is !== null. This works +// especially well with third-party libraries. Note that this pattern is even +// used for async functions that we write: we defensively write those functions +// to behave like regular async functions that return a Promise, and use +// ve() within the extension to translate that into a more readable error +// handling style. +// +// ve() converts a resolve or reject into a resolve that returns the pair of [T +// | null, Err | null]. Therefore, when declaring our own extension-"internal" +// functions, it still makes sense to call ve() in order that we have +// consistency with calls to third-party code. But also to be defensive in case +// such a function is called by third party code (and hence would expect a +// Promise type return). +// +// Promise functions are used when there is no return value. They are +// equivalent to a Go function that results only in an error. In this +// situation, ve() is still used to wrap the call; it translates a +// resolve/reject into a +// +// Output log +// =========== +// A single output channel +// (https://code.visualstudio.com/api/references/vscode-api#OutputChannel) is +// used to log useful but non-sensitive information about the extension. In +// case something goes wrong, glancing at the output logs is the first port of +// call to determine what was happening. In VSCode, this output can be seen via +// the 'Output' window, then selecting 'CUE' from the dropdown that allows you +// to select the category of output to view. If the LSP is active, then we +// create a separate output channel for the log messages received from the LSP +// client. That can be seen by selecting 'CUE Language Server'. +// +// Note that a single instance is used across instances of this extension. That +// way we don't clutter the user's VSCode Output window with multiple 'CUE' +// entries in the dropdown, entries that are indistinguishable by name. + +// inst is the global that holds the state for the singleton running instance +// of the vscode-cue extension, if there is one, in the current VSCode window +// (ultimately via the extension host). The extension's configuration +// (ultimately exposed via package.json) determines when an instance of this +// extension should be created. As part of that process, the activate function +// below is called. That is effectively the entrypoint for this and indeed any +// extension. An instance of Extension is then created to wrap the context for +// the extension instance and represent its lifetime. When an instance of the +// extension is restarted, or shutdown for whatever reason, the deactivate +// function below is called, in which we perform any final tidy-ups via +// inst.tearDown, and then set inst to undefiend to represent the start of the +// extension not current being active in the current VSCode window. +let inst: Extension | undefined; + +// output is a singleton output channel used for extension logging (at various +// levels) to the user via the 'Output' window, in the 'CUE' context. +let output = vscode.window.createOutputChannel('CUE', { log: true }); + +// lspOutput is established as a singleton the first time we run cue lsp. Log +// and error output from the running cue lsp instance is logged to the output +// channel, named 'CUE Language Server'. It is retained here as global state +// in order that we can reuse a singleton when created between instances of +// the extension, i.e. the output channel survives restarts. +let lspOutput: vscode.OutputChannel | undefined; + +// activate is the entrypoint for an instance of the extension. It is part of +// the VSCode extension API. +export async function activate(context: vscode.ExtensionContext): Promise { + // Verify that we are in either folder or workspace mode. For now, untitled + // workspace mode is not supported, at least until we better understand the + // implications. + if (!vscode.workspace.workspaceFolders) { + vscode.window.showErrorMessage('No workspace or folder open. CUE extension will not activate.'); + return Promise.resolve(); + } + + output.info('extension activated'); + + // Based on our current understanding, it should never be the case that inst + // is defined at this point. + if (inst !== undefined) { + throw new Error('inst already defined on activate?'); + } + + // An instance of Extension represents the active extension instance, in this + // VSCode window. + inst = new Extension(context, output, lspOutput); } -// This method is called when your extension is deactivated -export function deactivate() {} +// deactivate is called when the extension instance is shutting down. It is +// part of the VSCode extension API. It is also called when a VSCode extension +// is being restarted, followed immediately (assuming no errors results) by a +// call to activate. +export async function deactivate(): Promise { + if (inst === undefined) { + // Nothing to do + return Promise.resolve(undefined); + } + + // Grab the instance of the lsp output channel if there is one to reuse it + // between instances of the extension. This is relevant for example if the + // extension is restarted. + lspOutput = inst.tearDown(); + inst = undefined; + + output.info('extension deactivated'); + + return Promise.resolve(undefined); +} diff --git a/extension/testing.md b/extension/testing.md new file mode 100644 index 0000000..1267ec4 --- /dev/null +++ b/extension/testing.md @@ -0,0 +1,19 @@ +## Testing + +For now, we do not have any (meaningful) end-to-end/integration tests. For now +therefore we run through the following scenarios offline using VSCode. This list +could/should form the basis for the later addition of real automated tests that +cover these situations: + +1. Ensuring that we get an error message when opening a CUE file in untitled + workspace mode (see comment 'Extension instances and configuration'). +2. Ensuring that we get an error message when trying to start the LSP via the + command, but in a configuration state of `useLanguageServer: false`. +3. Ensuring we get an error message when the cmd/cue version used does not + support the LSP (as determined by `cue help lsp` returning an error). e.g. + by using v0.10.0. +4. Ensuring we get an error when the configured `languageServerCommand` is a + non-simple relative path, e.g. `./cue`. +5. Verifying that the extension sees and response to runtime configuration + changes, and that `cue lsp` is started/stopped according to the resulting + configuration.