diff --git a/extension/extension.cue b/extension/extension.cue index ba5831b..335a2a1 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 2b2c58d..87afe8f 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 065a0c4..d90ac42 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..d593142 100644 --- a/extension/src/main.ts +++ b/extension/src/main.ts @@ -1,25 +1,424 @@ -// 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 we use global +// state for the extension 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). +// +// Error 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 +// model. 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. +// +// ve() converst 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. +// +// 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 +// +// Console log +// ----------- +// console.log calls are used to log useful but non-sensitive information in a +// non-verbose manner. In case something goes wrong, glancing at the console +// logs is the first port of call to determine what was happening. + +// ctx is the global that represents the context of the active extension. +// +// 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 running 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. +var client: lcnode.LanguageClient | undefined; + +var clientStateChangeHandler: lcnode.Disposable | undefined; + +// config represents the configuration of the vscode-cue extension as loaded +// at activation time. +// +// TODO: handle dynamic changes in configuration. Some may require a restart +// (or stopping) of 'cue lsp'. +var config: CueConfiguration; + +// 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 = { +type CueConfiguration = { + useLanguageServer: boolean; + languageServerCommand: string[]; + languageServerFlags: string[]; +}; + +export async function activate(context: vscode.ExtensionContext): Promise { + config = applyConfigDefaults(vscode.workspace.getConfiguration('cue') as unknown as CueConfiguration); + + console.log(`vscode-cue activated with configuration: ${JSON.stringify(config, null, 2)}`); + + // Only set ctx now that we know this is a supported platform. + ctx = context; + + registerCommand('vscode-cue.welcome', cmdWelcomeCUE); + registerCommand('vscode-cue.startlsp', cmdStartLSP); + registerCommand('vscode-cue.stoplsp', cmdStopLSP); + + // run 'cue lsp' async from the main activate thread + runCueLsp(); +} + +// applyConfigDefaults updates c to apply defaults. Note this mutates c, which +// it is assumed is safe because the value will have come from a call to +// getConfiguration. +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 + }; +} + +export async function deactivate(): Promise { + 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; + + console.log('vscode-cue 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. +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. +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. +async function cmdStartLSP(context?: any): Promise { + if (!config.useLanguageServer) { + // TODO(myitcv): should we instead return an error here? be + // showErrorMessage instead? Possibly, because the running of a command is + // non-blocking AFAICT. And consistently using showErrorMessage feels + // better than + vscode.window.showErrorMessage(`useLanguageServer is configured to false`); + return Promise.resolve(); + } + return runCueLsp(); +} + +// cmdStopLSP is used to explicitly stop the LSP server. +async function cmdStopLSP(context?: any): Promise { + return stopCueLsp(); +} + +// 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. +async function runCueLsp(): Promise { + let err, _; + + if (!config.useLanguageServer) { + // Nothing to do. Explicit attempts to start the LSP in this situation are + // handled elsewhere. + return Promise.resolve(); + } + + // Stop the running instance if there is one. + [_, err] = await ve(stopCueLsp()); + if (err !== null) { + return Promise.reject(err); + } -// 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'); + // 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(); + } + + 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(command)}: ${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(); + } + + // If the extension is launched in debug mode then the debug server options + // are used Otherwise the run options are used + 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). + }; + + console.log(`starting CUE LSP with server options: ${JSON.stringify(serverOptions, null, 2)}`); + + // 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' }] + }; + + // Create the language client and start the client. + // + // TODO: handle the client/server dying and informing the user. + 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); + + 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(); +} + +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)); + console.log(`cue lsp client state change: from ${oldState} to ${newState}`); +} + +// stopCueLsp kills the running LSP client, if there is one. +async function stopCueLsp(): Promise { + if (client === undefined) { + return Promise.resolve(); + } + + // 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(); +} + +type Cmd = { + Args: string[]; + Stdout?: string; + Stderr?: string; + Err?: cp.ExecFileException | null; +}; + +// osexecRun is a Go os/exec.Cmd.Run rip-off. +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') + ); +} - context.subscriptions.push(disposable); +// 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; } -// 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]; + } + ); +}