diff --git a/extension/extension.cue b/extension/extension.cue index b6b39ff..1b4af2d 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,49 @@ 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: see comment above reference to cmdToggleAutoRestartLSP + // reference in activate function. + // { + // command: "vscode-cue.toggleautorestart" + // title: "CUE: Toggle CUE LSP auto-restart" + // }, + ] + + // TODO: switch to being the result of a JSON Schema "export" + configuration: { + type: "object" + title: "CUE" + properties: { + "cue.useLanguageServer": { + type: "boolean" + default: true + description: "Enable cuepls, 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/npm.cue b/extension/npm.cue index cfa1b72..34183fd 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" @@ -12,5 +12,9 @@ extension: npm: devDependencies: { esbuild: "0.21.5" eslint: "8.57.0" "npm-run-all": "4.1.5" + tar: "7.4.3" 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..571229d 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,13 @@ "esbuild": "0.21.5", "eslint": "8.57.0", "npm-run-all": "4.1.5", - "typescript": "5.4.5" + "tar": "7.4.3", + "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": { @@ -766,6 +770,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -878,9 +895,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 +2405,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", @@ -4804,6 +4844,52 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -6713,6 +6799,24 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", @@ -6800,6 +6904,26 @@ "node": ">= 6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -7171,6 +7295,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 +7375,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 +7427,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..e61bf3d 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 cuepls, 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,10 @@ "esbuild": "0.21.5", "eslint": "8.57.0", "npm-run-all": "4.1.5", - "typescript": "5.4.5" + "tar": "7.4.3", + "typescript": "5.4.5", + "@types/which": "3.0.4", + "vscode-languageclient": "9.0.1", + "which": "5.0.0" } } diff --git a/extension/src/main.ts b/extension/src/main.ts index 3a151b5..e441b55 100644 --- a/extension/src/main.ts +++ b/extension/src/main.ts @@ -1,25 +1,614 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below +'use strict'; + +import * as tar from 'tar'; +import * as fs from 'node:fs'; import * as vscode from 'vscode'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import crypto = require('crypto'); +import * as stream from 'node:stream'; +import which from 'which'; +import * as cp from 'node:child_process'; + +import * as lcnode from 'vscode-languageclient/node'; +import * as lc from 'vscode-languageclient'; + +// 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 for another day. Any poor style, etc, +// is my own. +// +// Structure of the extension +// ========================== +// This will probably come back to bite us down the line, but, like vscode-go, +// we use global state for the extension. One of the nice side effects +// (drivers?) is to make writing code cleaner. It might seem like a small +// thing, but having 99% of code indented within a class or namespace becomes a +// tiring waste of space, coming from Go. This does have the notable side +// effect that we don't have a sensible value for 'this', but given that global +// state is always available, this doesn't feel like a big loss. +// +// In a similar vein, everything will start in a single .ts file to avoid the +// mess of importing from multiple files in the same directory (I can't seem to +// find a clean analog for 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. +// +// 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 +// =========== +// An 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'. + +// ctx is the global that represents the context of the active extension +// instance. A value of undefined indicates the extension is not active on the +// current system (for example, the current system might not be a supported +// platform or architecture). +var ctx: vscode.ExtensionContext | undefined; + +// client is the active LSP client. This variable may only be defined if ctx +// !== undefined. If ctx !== undefined, then 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". +var client: lcnode.LanguageClient | undefined; + +// clientStateChangeHandler is the event handler that is called back when the +// state of the running LSP client changes. +var clientStateChangeHandler: lcnode.Disposable | undefined; + +// 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. +var config: CueConfiguration; + +// configChangeListener is a handler for changes in the extension instance +// configuration. It will only be defined for an active instance of the +// extension. +var configChangeListener: lcnode.Disposable | undefined; + +// 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[]; +}; + +// 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. +let manualLspStop = false; + +// 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 }); + +// lspOutputChannel 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'. +let lspOutputChannel: vscode.OutputChannel; + +// 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'); + + ctx = context; + configChangeListener = vscode.workspace.onDidChangeConfiguration(extensionConfigurationChange); + + registerCommand('vscode-cue.welcome', cmdWelcomeCUE); + registerCommand('vscode-cue.startlsp', cmdStartLSP); + registerCommand('vscode-cue.stoplsp', 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. + extensionConfigurationChange(undefined); +} + +// deactivate is called when the extension instance is shutting down. It is +// part of the VSCode extension API. +export async function deactivate(): Promise { + output.info('vscode-cue deactivate'); + + if (ctx === undefined) { + // Nothing to do + return Promise.resolve(undefined); + } + + // TODO: unclear where this is documented, but it appears that disposable things + // disposed _after_ a call to deactivate. That probably makes sense... but we need + // to be careful about the invariants on state. + // + // On a related point, we do not dispose of things ourselves here, instead we rely + // on the registration of "things to dispose". Hopefully we get the order right. + ctx = undefined; + + output.info('extension deactivated'); + + return Promise.resolve(undefined); +} + +// 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. +function registerCommand(cmd: string, callback: (context?: any) => Promise) { + var disposable = vscode.commands.registerCommand(cmd, callback); + ctx!.subscriptions.push(disposable); +} + +// cmdWelcomeCUE is a basic command that can be used to verify whether the +// vscode-cue extension is loaded at all (beyond checking output logs). +async function cmdWelcomeCUE(context?: any): Promise { + 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. +async function cmdStartLSP(context?: any): Promise { + if (!config.useLanguageServer) { + vscode.window.showErrorMessage(`useLanguageServer is configured to false`); + return Promise.resolve(); + } + manualLspStop = false; + return startCueLsp('manually'); +} + +// cmdStopLSP is used to explicitly stop the LSP server. +async function cmdStopLSP(context?: any): Promise { + manualLspStop = true; + return stopCueLsp('manually'); +} + +// runCueLsp 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". +async function startCueLsp(source: string = ''): Promise { + let err, _; + + if (!config.useLanguageServer || 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} `; + } + + // Stop the running instance if there is one. + [_, err] = await ve(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 = 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(); + } -// 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'); + 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: [...config.languageServerCommand.slice(1), ...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). + }; + + 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 (lspOutputChannel === undefined) { + lspOutputChannel = 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: lspOutputChannel + }; + + // Create the language client + // + // TODO: properly handle the client/server dying and informing the user, + // beyond the simple notification of state change. + client = new lcnode.LanguageClient('cue lsp', 'cue lsp: the CUE Language Server', serverOptions, clientOptions); + clientStateChangeHandler = client.onDidChangeState(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). + ctx!.subscriptions.push(clientStateChangeHandler); + + // Start the client, which in turn will start 'cue lsp' + client.start(); + ctx!.subscriptions.push(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. +async function stopCueLsp(source: string = ''): Promise { + if (client === undefined) { + return Promise.resolve(); + } + + if (source !== '') { + source = `${source} `; + } + + output.info(`${source}stopping cue lsp`); + + // Stop listening to the event first so that we don't trigger a restart + clientStateChangeHandler!.dispose(); + + let _, err; + // TODO: use a different timeout? + [_, err] = await ve(client.stop()); + client = undefined; + 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(); +} + +// 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'; + } +} + +function cueLspStateChange(s: lc.StateChangeEvent): void { + var oldState = JSON.stringify(humanReadableState(s.oldState)); + var newState = JSON.stringify(humanReadableState(s.newState)); + output.info(`cue lsp client state change: from ${oldState} to ${newState}`); +} + +// 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). +async function extensionConfigurationChange(s: vscode.ConfigurationChangeEvent | undefined): Promise { + // 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; + config = applyConfigDefaults(configCopy); + output.info(`configuration updated to: ${JSON.stringify(config, null, 2)}`); + + let _, err; + if (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(startCueLsp()); + } else { + [_, err] = await ve(stopCueLsp()); + } + if (err !== null) { + return Promise.reject(err); + } + + return Promise.resolve(); +} + +// 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 + }; +} + +// 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 }; - context.subscriptions.push(disposable); +// isArbitraryObject is used as part of isErrnoException. +function isArbitraryObject(potentialObject: unknown): potentialObject is ArbitraryObject { + return typeof potentialObject === 'object' && potentialObject !== null; } -// This method is called when your extension is deactivated -export function deactivate() {} +// Err is a convenience type for a nullable error, much like error in Go +type Err = Error | null; + +// 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]; + } + ); +}