Move lsp server to this repo (#5619)

This commit is contained in:
Jess Frazelle
2025-03-04 22:21:12 -08:00
committed by GitHub
parent e8af61e11f
commit 37715d9fa8
47 changed files with 5929 additions and 28 deletions

View File

@ -0,0 +1,162 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as vscode from 'vscode'
import * as os from 'os'
import type { Config } from './config'
import { log, isValidExecutable } from './util'
import type { PersistentState } from './persistent_state'
import { exec } from 'child_process'
export async function bootstrap(
context: vscode.ExtensionContext,
config: Config,
state: PersistentState
): Promise<string> {
const path = await getServer(context, config, state)
if (!path) {
throw new Error(
'KittyCAD Language Server is not available. ' +
'Please, ensure its [proper installation](https://github.com/kittycad/kcl-lsp).'
)
}
log.info('Using server binary at', path)
if (!isValidExecutable(path)) {
if (config.serverPath) {
throw new Error(`Failed to execute ${path} --version. \`config.server.path\` or \`config.serverPath\` has been set explicitly.\
Consider removing this config or making a valid server binary available at that path.`)
} else {
throw new Error(`Failed to execute ${path} --version`)
}
}
return path
}
async function getServer(
context: vscode.ExtensionContext,
config: Config,
state: PersistentState
): Promise<string | undefined> {
const explicitPath =
process.env['__KCL_LSP_SERVER_DEBUG'] ?? config.serverPath
if (explicitPath) {
if (explicitPath.startsWith('~/')) {
return os.homedir() + explicitPath.slice('~'.length)
}
return explicitPath
}
if (config.package.releaseTag === null) return 'kcl-language-server'
const ext = process.platform === 'win32' ? '.exe' : ''
const bundled = vscode.Uri.joinPath(
context.extensionUri,
'server',
`kcl-language-server${ext}`
)
log.info('Checking if bundled server exists at', bundled)
const bundledExists = await vscode.workspace.fs.stat(bundled).then(
() => true,
() => false
)
log.info('Bundled server exists:', bundledExists)
if (bundledExists) {
let server = bundled
if (await isNixOs()) {
await vscode.workspace.fs.createDirectory(config.globalStorageUri).then()
const dest = vscode.Uri.joinPath(
config.globalStorageUri,
`kcl-language-server${ext}`
)
let exists = await vscode.workspace.fs.stat(dest).then(
() => true,
() => false
)
if (exists && config.package.version !== state.serverVersion) {
log.info(
'Server version changed, removing old server binary',
config.package.version,
state.serverVersion
)
await vscode.workspace.fs.delete(dest)
exists = false
}
if (!exists) {
await vscode.workspace.fs.copy(bundled, dest)
await patchelf(dest)
}
server = dest
}
await state.updateServerVersion(config.package.version)
return server.fsPath
}
await state.updateServerVersion(undefined)
await vscode.window.showErrorMessage(
"Unfortunately we don't ship binaries for your platform yet. " +
'You need to manually clone the kcl-lsp repository and ' +
'run `cargo install` to build the language server from sources. ' +
'If you feel that your platform should be supported, please create an issue ' +
'about that [here](https://github.com/kittycad/kcl-lsp/issues) and we ' +
'will consider it.'
)
return undefined
}
async function isNixOs(): Promise<boolean> {
try {
const contents = (
await vscode.workspace.fs.readFile(vscode.Uri.file('/etc/os-release'))
).toString()
const idString =
contents.split('\n').find((a) => a.startsWith('ID=')) || 'ID=linux'
return idString.indexOf('nixos') !== -1
} catch {
return false
}
}
async function patchelf(dest: vscode.Uri): Promise<void> {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Patching kcl-language-server for NixOS',
},
async (progress, _) => {
const expression = `
{srcStr, pkgs ? import <nixpkgs> {}}:
pkgs.stdenv.mkDerivation {
name = "kcl-language-server";
src = /. + srcStr;
phases = [ "installPhase" "fixupPhase" ];
installPhase = "cp $src $out";
fixupPhase = ''
chmod 755 $out
patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" $out
'';
}
`
const origFile = vscode.Uri.file(dest.fsPath + '-orig')
await vscode.workspace.fs.rename(dest, origFile, { overwrite: true })
try {
progress.report({ message: 'Patching executable', increment: 20 })
await new Promise((resolve, reject) => {
const handle = exec(
`nix-build -E - --argstr srcStr '${origFile.fsPath}' -o '${dest.fsPath}'`,
(err, stdout, stderr) => {
if (err != null) {
reject(Error(stderr))
} else {
resolve(stdout)
}
}
)
handle.stdin?.write(expression)
handle.stdin?.end()
})
} finally {
await vscode.workspace.fs.delete(origFile)
}
}
)
}

View File

@ -0,0 +1,74 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as lc from 'vscode-languageclient/node'
import type * as vscode from 'vscode'
export async function createClient(
traceOutputChannel: vscode.OutputChannel,
outputChannel: vscode.OutputChannel,
initializationOptions: vscode.WorkspaceConfiguration,
serverOptions: lc.ServerOptions
): Promise<lc.LanguageClient> {
const clientOptions: lc.LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'kcl' }],
initializationOptions,
traceOutputChannel,
outputChannel,
middleware: {
workspace: {
// HACK: This is a workaround, when the client has been disposed, VSCode
// continues to emit events to the client and the default one for this event
// attempt to restart the client for no reason
async didChangeWatchedFile(event: any, next: any) {
if (client.isRunning()) {
await next(event)
}
},
async configuration(
params: lc.ConfigurationParams,
token: vscode.CancellationToken,
next: lc.ConfigurationRequest.HandlerSignature
) {
const resp = await next(params, token)
return resp
},
},
},
}
const client = new lc.LanguageClient(
'kcl-language-server',
'KittyCAD Language Server',
serverOptions,
clientOptions
)
client.registerFeature(new ExperimentalFeatures())
return client
}
class ExperimentalFeatures implements lc.StaticFeature {
getState(): lc.FeatureState {
return { kind: 'static' }
}
fillClientCapabilities(capabilities: lc.ClientCapabilities): void {
capabilities.experimental = {
snippetTextEdit: true,
codeActionGroup: true,
hoverActions: true,
serverStatusNotification: true,
colorDiagnosticOutput: true,
openServerLogs: true,
commands: {
commands: ['editor.action.triggerParameterHints'],
},
...capabilities.experimental,
}
}
initialize(
_capabilities: lc.ServerCapabilities,
_documentSelector: lc.DocumentSelector | undefined
): void {}
dispose(): void {}
clear(): void {}
}

View File

@ -0,0 +1,32 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as vscode from 'vscode'
import type { Cmd, CtxInit } from './ctx'
import { spawnSync } from 'child_process'
export function serverVersion(ctx: CtxInit): Cmd {
return async () => {
if (!ctx.serverPath) {
void vscode.window.showWarningMessage(
`kcl-language-server server is not running`
)
return
}
const { stdout } = spawnSync(ctx.serverPath, ['--version'], {
encoding: 'utf8',
})
const versionString = stdout.slice(`kcl-language-server `.length).trim()
void vscode.window.showInformationMessage(
`kcl-language-server version: ${versionString}`
)
}
}
export function openLogs(ctx: CtxInit): Cmd {
return async () => {
if (ctx.client.outputChannel) {
ctx.client.outputChannel.show()
}
}
}

View File

@ -0,0 +1,293 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as Is from 'vscode-languageclient/lib/common/utils/is'
import * as os from 'os'
import * as path from 'path'
import * as vscode from 'vscode'
import { log, type Env } from './util'
import { expectNotUndefined, unwrapUndefinable } from './undefinable'
export type RunnableEnvCfgItem = {
mask?: string
env: Record<string, string>
platform?: string | string[]
}
export type RunnableEnvCfg =
| undefined
| Record<string, string>
| RunnableEnvCfgItem[]
export class Config {
readonly extensionId = 'kittycad.kcl-language-server'
configureLang: vscode.Disposable | undefined
readonly rootSection = 'kcl-language-server'
private readonly requiresReloadOpts = ['serverPath', 'server', 'files'].map(
(opt) => `${this.rootSection}.${opt}`
)
readonly package: {
version: string
releaseTag: string | null
enableProposedApi: boolean | undefined
} = vscode.extensions.getExtension(this.extensionId)!.packageJSON
readonly globalStorageUri: vscode.Uri
constructor(ctx: vscode.ExtensionContext) {
this.globalStorageUri = ctx.globalStorageUri
vscode.workspace.onDidChangeConfiguration(
this.onDidChangeConfiguration,
this,
ctx.subscriptions
)
this.refreshLogging()
}
dispose() {
this.configureLang?.dispose()
}
private refreshLogging() {
log.setEnabled(this.traceExtension ?? false)
log.info('Extension version:', this.package.version)
const cfg = Object.entries(this.cfg).filter(
([_, val]) => !(val instanceof Function)
)
log.info('Using configuration', Object.fromEntries(cfg))
}
private async onDidChangeConfiguration(
event: vscode.ConfigurationChangeEvent
) {
this.refreshLogging()
const requiresReloadOpt = this.requiresReloadOpts.find((opt) =>
event.affectsConfiguration(opt)
)
if (!requiresReloadOpt) return
const message = `Changing "${requiresReloadOpt}" requires a server restart`
const userResponse = await vscode.window.showInformationMessage(
message,
'Restart now'
)
if (userResponse) {
const command = 'kcl-language-server.restartServer'
await vscode.commands.executeCommand(command)
}
}
// We don't do runtime config validation here for simplicity. More on stackoverflow:
// https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension
private get cfg(): vscode.WorkspaceConfiguration {
return vscode.workspace.getConfiguration(this.rootSection)
}
/**
* Beware that postfix `!` operator erases both `null` and `undefined`.
* This is why the following doesn't work as expected:
*
* ```ts
* const nullableNum = vscode
* .workspace
* .getConfiguration
* .getConfiguration("kcl-language-server")
* .get<number | null>(path)!;
*
* // What happens is that type of `nullableNum` is `number` but not `null | number`:
* const fullFledgedNum: number = nullableNum;
* ```
* So this getter handles this quirk by not requiring the caller to use postfix `!`
*/
private get<T>(path: string): T | undefined {
return prepareVSCodeConfig(this.cfg.get<T>(path))
}
get serverPath() {
return (
this.get<null | string>('server.path') ??
this.get<null | string>('serverPath')
)
}
get traceExtension() {
return this.get<boolean>('trace.extension')
}
}
// the optional `cb?` parameter is meant to be used to add additional
// key/value pairs to the VS Code configuration. This needed for, e.g.,
// including a `rust-project.json` into the `linkedProjects` key as part
// of the configuration/InitializationParams _without_ causing VS Code
// configuration to be written out to workspace-level settings. This is
// undesirable behavior because rust-project.json files can be tens of
// thousands of lines of JSON, most of which is not meant for humans
// to interact with.
export function prepareVSCodeConfig<T>(
resp: T,
cb?: (key: Extract<keyof T, string>, res: { [key: string]: any }) => void
): T {
if (Is.string(resp)) {
return substituteVSCodeVariableInString(resp) as T
} else if (resp && Is.array<any>(resp)) {
return resp.map((val) => {
return prepareVSCodeConfig(val)
}) as T
} else if (resp && typeof resp === 'object') {
const res: { [key: string]: any } = {}
for (const key in resp) {
const val = resp[key]
res[key] = prepareVSCodeConfig(val)
if (cb) {
cb(key, res)
}
}
return res as T
}
return resp
}
// FIXME: Merge this with `substituteVSCodeVariables` above
export function substituteVariablesInEnv(env: Env): Env {
const missingDeps = new Set<string>()
// vscode uses `env:ENV_NAME` for env vars resolution, and it's easier
// to follow the same convention for our dependency tracking
const definedEnvKeys = new Set(Object.keys(env).map((key) => `env:${key}`))
const envWithDeps = Object.fromEntries(
Object.entries(env).map(([key, value]) => {
const deps = new Set<string>()
const depRe = new RegExp(/\${(?<depName>.+?)}/g)
let match = undefined
while ((match = depRe.exec(value))) {
const depName = unwrapUndefinable(match.groups?.['depName'])
deps.add(depName)
// `depName` at this point can have a form of `expression` or
// `prefix:expression`
if (!definedEnvKeys.has(depName)) {
missingDeps.add(depName)
}
}
return [`env:${key}`, { deps: [...deps], value }]
})
)
const resolved = new Set<string>()
for (const dep of missingDeps) {
const match = /(?<prefix>.*?):(?<body>.+)/.exec(dep)
if (match) {
const { prefix, body } = match.groups!
if (prefix === 'env') {
const envName = unwrapUndefinable(body)
envWithDeps[dep] = {
value: process.env[envName] ?? '',
deps: [],
}
resolved.add(dep)
} else {
// we can't handle other prefixes at the moment
// leave values as is, but still mark them as resolved
envWithDeps[dep] = {
value: '${' + dep + '}',
deps: [],
}
resolved.add(dep)
}
} else {
envWithDeps[dep] = {
value: computeVscodeVar(dep) || '${' + dep + '}',
deps: [],
}
}
}
const toResolve = new Set(Object.keys(envWithDeps))
let leftToResolveSize
do {
leftToResolveSize = toResolve.size
for (const key of toResolve) {
const item = unwrapUndefinable(envWithDeps[key])
if (item.deps.every((dep) => resolved.has(dep))) {
item.value = item.value.replace(
/\${(?<depName>.+?)}/g,
(_wholeMatch, depName) => {
const item = unwrapUndefinable(envWithDeps[depName])
return item.value
}
)
resolved.add(key)
toResolve.delete(key)
}
}
} while (toResolve.size > 0 && toResolve.size < leftToResolveSize)
const resolvedEnv: Env = {}
for (const key of Object.keys(env)) {
const item = unwrapUndefinable(envWithDeps[`env:${key}`])
resolvedEnv[key] = item.value
}
return resolvedEnv
}
const VarRegex = new RegExp(/\$\{(.+?)\}/g)
function substituteVSCodeVariableInString(val: string): string {
return val.replace(VarRegex, (substring: string, varName) => {
if (Is.string(varName)) {
return computeVscodeVar(varName) || substring
} else {
return substring
}
})
}
function computeVscodeVar(varName: string): string | null {
const workspaceFolder = () => {
const folders = vscode.workspace.workspaceFolders ?? []
const folder = folders[0]
// TODO: support for remote workspaces?
const fsPath: string =
folder === undefined
? // no workspace opened
''
: // could use currently opened document to detect the correct
// workspace. However, that would be determined by the document
// user has opened on Editor startup. Could lead to
// unpredictable workspace selection in practice.
// It's better to pick the first one
folder.uri.fsPath
return fsPath
}
// https://code.visualstudio.com/docs/editor/variables-reference
const supportedVariables: { [k: string]: () => string } = {
workspaceFolder,
workspaceFolderBasename: () => {
return path.basename(workspaceFolder())
},
cwd: () => process.cwd(),
userHome: () => os.homedir(),
// see
// https://github.com/microsoft/vscode/blob/08ac1bb67ca2459496b272d8f4a908757f24f56f/src/vs/workbench/api/common/extHostVariableResolverService.ts#L81
// or
// https://github.com/microsoft/vscode/blob/29eb316bb9f154b7870eb5204ec7f2e7cf649bec/src/vs/server/node/remoteTerminalChannel.ts#L56
execPath: () => process.env['VSCODE_EXEC_PATH'] ?? process.execPath,
pathSeparator: () => path.sep,
}
if (varName in supportedVariables) {
const fn = expectNotUndefined(
supportedVariables[varName],
`${varName} should not be undefined here`
)
return fn()
} else {
// return "${" + varName + "}";
return null
}
}

View File

@ -0,0 +1,387 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as vscode from 'vscode'
import type * as lc from 'vscode-languageclient/node'
import { Config, prepareVSCodeConfig } from './config'
import { createClient } from './client'
import {
isKclDocument,
isKclEditor,
LazyOutputChannel,
log,
type KclEditor,
} from './util'
import type { ServerStatusParams } from './lsp_ext'
import { PersistentState } from './persistent_state'
import { bootstrap } from './bootstrap'
import { TransportKind } from 'vscode-languageclient/node'
// We only support local folders, not eg. Live Share (`vlsl:` scheme), so don't activate if
// only those are in use. We use "Empty" to represent these scenarios
// (r-a still somewhat works with Live Share, because commands are tunneled to the host)
export type Workspace =
| { kind: 'Empty' }
| {
kind: 'Workspace Folder'
}
| {
kind: 'Detached Files'
files: vscode.TextDocument[]
}
export function fetchWorkspace(): Workspace {
const folders = (vscode.workspace.workspaceFolders || []).filter(
(folder) => folder.uri.scheme === 'file'
)
const kclDocuments = vscode.workspace.textDocuments.filter((document) =>
isKclDocument(document)
)
return folders.length === 0
? kclDocuments.length === 0
? { kind: 'Empty' }
: {
kind: 'Detached Files',
files: kclDocuments,
}
: { kind: 'Workspace Folder' }
}
export type CommandFactory = {
enabled: (ctx: CtxInit) => Cmd
disabled?: (ctx: Ctx) => Cmd
}
export type CtxInit = Ctx & {
readonly client: lc.LanguageClient
}
export class Ctx {
readonly statusBar: vscode.StatusBarItem
config: Config
readonly workspace: Workspace
private _client: lc.LanguageClient | undefined
private _serverPath: string | undefined
private traceOutputChannel: vscode.OutputChannel | undefined
private outputChannel: vscode.OutputChannel | undefined
private clientSubscriptions: Disposable[]
private state: PersistentState
private commandFactories: Record<string, CommandFactory>
private commandDisposables: Disposable[]
private lastStatus: ServerStatusParams | { health: 'stopped' } = {
health: 'stopped',
}
get client() {
return this._client
}
constructor(
readonly extCtx: vscode.ExtensionContext,
commandFactories: Record<string, CommandFactory>,
workspace: Workspace
) {
extCtx.subscriptions.push(this)
this.statusBar = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left
)
this.workspace = workspace
this.clientSubscriptions = []
this.commandDisposables = []
this.commandFactories = commandFactories
this.state = new PersistentState(extCtx.globalState)
this.config = new Config(extCtx)
this.updateCommands('disable')
this.setServerStatus({
health: 'stopped',
})
}
dispose() {
this.config.dispose()
this.statusBar.dispose()
void this.disposeClient()
this.commandDisposables.forEach((disposable) => disposable.dispose())
}
async onWorkspaceFolderChanges() {
const workspace = fetchWorkspace()
if (
workspace.kind === 'Detached Files' &&
this.workspace.kind === 'Detached Files'
) {
if (workspace.files !== this.workspace.files) {
if (this.client?.isRunning()) {
// Ideally we wouldn't need to tear down the server here, but currently detached files
// are only specified at server start
await this.stopAndDispose()
await this.start()
}
return
}
}
if (
workspace.kind === 'Workspace Folder' &&
this.workspace.kind === 'Workspace Folder'
) {
return
}
if (workspace.kind === 'Empty') {
await this.stopAndDispose()
return
}
if (this.client?.isRunning()) {
await this.restart()
}
}
private async getOrCreateClient() {
if (this.workspace.kind === 'Empty') {
return
}
if (!this.traceOutputChannel) {
this.traceOutputChannel = new LazyOutputChannel(
'KittyCAD Language Server Trace'
)
this.pushExtCleanup(this.traceOutputChannel)
}
if (!this.outputChannel) {
this.outputChannel = vscode.window.createOutputChannel(
'KittyCAD Language Server'
)
this.pushExtCleanup(this.outputChannel)
}
if (!this._client) {
this._serverPath = await bootstrap(
this.extCtx,
this.config,
this.state
).catch((err) => {
let message = 'bootstrap error. '
message +=
'See the logs in "OUTPUT > KittyCAD Language Client" (should open automatically). '
message +=
'To enable verbose logs use { "kcl-language-server.trace.extension": true }'
log.error('Bootstrap error', err)
throw new Error(message)
})
const run: lc.Executable = {
command: this._serverPath,
args: ['--json', 'server'],
transport: TransportKind.stdio,
options: { env: { ...process.env } },
}
const serverOptions = {
run,
debug: run,
}
let rawInitializationOptions = vscode.workspace.getConfiguration(
'kcl-language-server'
)
if (this.workspace.kind === 'Detached Files') {
rawInitializationOptions = {
detachedFiles: this.workspace.files.map((file) => file.uri.fsPath),
...rawInitializationOptions,
}
}
const initializationOptions = prepareVSCodeConfig(
rawInitializationOptions
)
this._client = await createClient(
this.traceOutputChannel,
this.outputChannel,
initializationOptions,
serverOptions
)
}
return this._client
}
async start() {
log.info('Starting language client')
const client = await this.getOrCreateClient()
if (!client) {
return
}
await client.start()
this.setServerStatus({ health: 'ok', quiescent: true })
this.updateCommands()
}
async restart() {
// FIXME: We should reuse the client, that is ctx.deactivate() if none of the configs have changed
await this.stopAndDispose()
await this.start()
}
async stop() {
if (!this._client) {
return
}
log.info('Stopping language client')
this.updateCommands('disable')
await this._client.stop()
}
async stopAndDispose() {
if (!this._client) {
return
}
log.info('Disposing language client')
this.updateCommands('disable')
await this.disposeClient()
}
private async disposeClient() {
this.clientSubscriptions?.forEach((disposable) => disposable.dispose())
this.clientSubscriptions = []
try {
await this._client?.dispose(2000)
} catch (e) {
// DO nothing.
}
this._serverPath = undefined
this._client = undefined
}
get activeKclEditor(): KclEditor | undefined {
const editor = vscode.window.activeTextEditor
return editor && isKclEditor(editor) ? editor : undefined
}
get extensionPath(): string {
return this.extCtx.extensionPath
}
get subscriptions(): Disposable[] {
return this.extCtx.subscriptions
}
get serverPath(): string | undefined {
return this._serverPath
}
private updateCommands(forceDisable?: 'disable') {
this.commandDisposables.forEach((disposable) => disposable.dispose())
this.commandDisposables = []
const clientRunning = (!forceDisable && this._client?.isRunning()) ?? false
const isClientRunning = function (_ctx: Ctx): _ctx is CtxInit {
return clientRunning
}
for (const [name, factory] of Object.entries(this.commandFactories)) {
const fullName = `kcl-language-server.${name}`
let callback
if (isClientRunning(this)) {
// we asserted that `client` is defined
callback = factory.enabled(this)
} else if (factory.disabled) {
callback = factory.disabled(this)
} else {
callback = () =>
vscode.window.showErrorMessage(
`command ${fullName} failed: kcl-language-server server is not running`
)
}
this.commandDisposables.push(
vscode.commands.registerCommand(fullName, callback)
)
}
}
setServerStatus(status: ServerStatusParams | { health: 'stopped' }) {
this.lastStatus = status
this.updateStatusBarItem()
}
refreshServerStatus() {
this.updateStatusBarItem()
}
private updateStatusBarItem() {
let icon = ''
const status = this.lastStatus
const statusBar = this.statusBar
statusBar.show()
statusBar.tooltip = new vscode.MarkdownString('', true)
statusBar.tooltip.isTrusted = true
switch (status.health) {
case 'ok':
statusBar.tooltip.appendText(status.message ?? 'Ready')
statusBar.color = undefined
statusBar.backgroundColor = undefined
statusBar.command = 'kcl-language-server.openLogs'
break
case 'warning':
if (status.message) {
statusBar.tooltip.appendText(status.message)
}
statusBar.color = new vscode.ThemeColor(
'statusBarItem.warningForeground'
)
statusBar.backgroundColor = new vscode.ThemeColor(
'statusBarItem.warningBackground'
)
statusBar.command = 'kcl-language-server.openLogs'
icon = '$(warning) '
break
case 'error':
if (status.message) {
statusBar.tooltip.appendText(status.message)
}
statusBar.color = new vscode.ThemeColor('statusBarItem.errorForeground')
statusBar.backgroundColor = new vscode.ThemeColor(
'statusBarItem.errorBackground'
)
statusBar.command = 'kcl-language-server.openLogs'
icon = '$(error) '
break
case 'stopped':
statusBar.tooltip.appendText('Server is stopped')
statusBar.tooltip.appendMarkdown(
'\n\n[Start server](command:kcl-language-server.startServer)'
)
statusBar.color = new vscode.ThemeColor(
'statusBarItem.warningForeground'
)
statusBar.backgroundColor = new vscode.ThemeColor(
'statusBarItem.warningBackground'
)
statusBar.command = 'kcl-language-server.startServer'
statusBar.text = '$(stop-circle) kcl-language-server'
return
}
if (statusBar.tooltip.value) {
statusBar.tooltip.appendMarkdown('\n\n---\n\n')
}
statusBar.tooltip.appendMarkdown(
'\n\n[Restart server](command:kcl-language-server.restartServer)'
)
statusBar.tooltip.appendMarkdown(
'\n\n[Stop server](command:kcl-language-server.stopServer)'
)
if (!status.quiescent) icon = '$(sync~spin) '
statusBar.text = `${icon}kcl-language-server`
}
pushExtCleanup(d: Disposable) {
this.extCtx.subscriptions.push(d)
}
}
export interface Disposable {
dispose(): void
}
export type Cmd = (...args: any[]) => unknown

View File

@ -0,0 +1,24 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as lc from 'vscode-languageclient'
export type CommandLink = {
/**
* A tooltip for the command, when represented in the UI.
*/
tooltip?: string
} & lc.Command
export type CommandLinkGroup = {
title?: string
commands: CommandLink[]
}
// experimental extensions
export const serverStatus = new lc.NotificationType<ServerStatusParams>(
'experimental/serverStatus'
)
export type ServerStatusParams = {
health: 'ok' | 'warning' | 'error'
quiescent: boolean
message?: string
}

View File

@ -0,0 +1,79 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as vscode from 'vscode'
import type * as lc from 'vscode-languageclient/node'
import * as commands from './commands'
import { type CommandFactory, Ctx, fetchWorkspace } from './ctx'
import { setContextValue } from './util'
const KCL_PROJECT_CONTEXT_NAME = 'inKclProject'
export interface KclAnalyzerExtensionApi {
readonly client?: lc.LanguageClient
}
export async function deactivate() {
await setContextValue(KCL_PROJECT_CONTEXT_NAME, undefined)
}
export async function activate(
context: vscode.ExtensionContext
): Promise<KclAnalyzerExtensionApi> {
const ctx = new Ctx(context, createCommands(), fetchWorkspace())
// VS Code doesn't show a notification when an extension fails to activate
// so we do it ourselves.
const api = await activateServer(ctx).catch((err) => {
void vscode.window.showErrorMessage(
`Cannot activate kcl-language-server extension: ${err.message}`
)
throw err
})
await setContextValue(KCL_PROJECT_CONTEXT_NAME, true)
return api
}
async function activateServer(ctx: Ctx): Promise<KclAnalyzerExtensionApi> {
await ctx.start()
return ctx
}
function createCommands(): Record<string, CommandFactory> {
return {
restartServer: {
enabled: (ctx) => async () => {
await ctx.restart()
},
disabled: (ctx) => async () => {
await ctx.start()
},
},
startServer: {
enabled: (ctx) => async () => {
await ctx.start()
ctx.setServerStatus({
health: 'ok',
quiescent: true,
})
},
disabled: (ctx) => async () => {
await ctx.start()
ctx.setServerStatus({
health: 'ok',
quiescent: true,
})
},
},
stopServer: {
enabled: (ctx) => async () => {
// FIXME: We should reuse the client, that is ctx.deactivate() if none of the configs have changed
await ctx.stopAndDispose()
ctx.setServerStatus({
health: 'stopped',
})
},
disabled: (_) => async () => {},
},
serverVersion: { enabled: commands.serverVersion },
openLogs: { enabled: commands.openLogs },
}
}

View File

@ -0,0 +1,21 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import type * as vscode from 'vscode'
import { log } from './util'
export class PersistentState {
constructor(private readonly globalState: vscode.Memento) {
const { serverVersion } = this
log.info('PersistentState:', { serverVersion })
}
/**
* Version of the extension that installed the server.
* Used to check if we need to run patchelf again on NixOS.
*/
get serverVersion(): string | undefined {
return this.globalState.get('serverVersion')
}
async updateServerVersion(value: string | undefined) {
await this.globalState.update('serverVersion', value)
}
}

View File

@ -0,0 +1,25 @@
import * as path from 'path'
import { runTests } from '@vscode/test-electron'
async function main() {
try {
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, '../../')
// The path to the extension test runner script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, './suite/index')
// Download VS Code, unzip it and run the integration test
await runTests({ extensionDevelopmentPath, extensionTestsPath })
} catch (err) {
console.error(err)
console.error('Failed to run tests')
process.exit(1)
}
}
/* eslint-disable */
main()

View File

@ -0,0 +1,16 @@
import * as assert from 'assert'
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode'
// import * as myExtension from '../../extension';
suite('Extension Test Suite', () => {
/* eslint-disable */
vscode.window.showInformationMessage('Start all tests.')
test('Sample test', () => {
assert.strictEqual([1, 2, 3].indexOf(5), -1)
assert.strictEqual([1, 2, 3].indexOf(0), -1)
})
})

View File

@ -0,0 +1,33 @@
import * as path from 'path'
const Mocha = require('mocha')
const { glob } = require('glob')
export function run(): Promise<void> {
// Create the mocha test
const mocha = new Mocha({
ui: 'tdd',
})
const testsRoot = path.resolve(__dirname, '..')
return new Promise((c, e) => {
glob('**/**.test.js', { cwd: testsRoot }).then((files: string[]) => {
// Add files to the test suite
files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)))
try {
// Run the mocha test
mocha.run((failures: any) => {
if (failures > 0) {
e(new Error(`${failures} tests failed.`))
} else {
c()
}
})
} catch (err) {
console.error(err)
e(err)
}
})
})
}

View File

@ -0,0 +1,23 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
export type NotUndefined<T> = T extends undefined ? never : T
export type Undefinable<T> = T | undefined
function isNotUndefined<T>(input: Undefinable<T>): input is NotUndefined<T> {
return input !== undefined
}
export function expectNotUndefined<T>(
input: Undefinable<T>,
msg: string
): NotUndefined<T> {
if (isNotUndefined(input)) {
return input
}
throw new TypeError(msg)
}
export function unwrapUndefinable<T>(input: Undefinable<T>): NotUndefined<T> {
return expectNotUndefined(input, `unwrapping \`undefined\``)
}

View File

@ -0,0 +1,240 @@
/* eslint suggest-no-throw/suggest-no-throw: 0 */
import * as vscode from 'vscode'
import { strict as nativeAssert } from 'assert'
import { exec, type ExecOptions, spawnSync } from 'child_process'
import { inspect } from 'util'
export interface Env {
[name: string]: string
}
export function assert(
condition: boolean,
explanation: string
): asserts condition {
try {
nativeAssert(condition, explanation)
} catch (err) {
log.error(`Assertion failed:`, explanation)
throw err
}
}
class Logger {
private enabled = true
private readonly output = vscode.window.createOutputChannel(
'KittyCAD Language Client'
)
setEnabled(yes: boolean): void {
log.enabled = yes
}
// Hint: the type [T, ...T[]] means a non-empty array
debug(...msg: [unknown, ...unknown[]]): void {
if (!log.enabled) return
log.write('DEBUG', ...msg)
}
info(...msg: [unknown, ...unknown[]]): void {
log.write('INFO', ...msg)
}
warn(...msg: [unknown, ...unknown[]]): void {
debugger
log.write('WARN', ...msg)
}
error(...msg: [unknown, ...unknown[]]): void {
debugger
log.write('ERROR', ...msg)
log.output.show(true)
}
private write(label: string, ...messageParts: unknown[]): void {
const message = messageParts.map(log.stringify).join(' ')
const dateTime = new Date().toLocaleString()
log.output.appendLine(`${label} [${dateTime}]: ${message}`)
}
private stringify(val: unknown): string {
if (typeof val === 'string') return val
return inspect(val, {
colors: false,
depth: 6, // heuristic
})
}
}
export const log = new Logger()
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export type KclDocument = vscode.TextDocument & { languageId: 'kcl' }
export type KclEditor = vscode.TextEditor & { document: KclDocument }
export function isKclDocument(
document: vscode.TextDocument
): document is KclDocument {
// Prevent corrupted text (particularly via inlay hints) in diff views
// by allowing only `file` schemes
// unfortunately extensions that use diff views not always set this
// to something different than 'file' (see ongoing bug: #4608)
return document.languageId === 'kcl' && document.uri.scheme === 'file'
}
export function isCargoTomlDocument(
document: vscode.TextDocument
): document is KclDocument {
// ideally `document.languageId` should be 'toml' but user maybe not have toml extension installed
return (
document.uri.scheme === 'file' && document.fileName.endsWith('Cargo.toml')
)
}
export function isKclEditor(editor: vscode.TextEditor): editor is KclEditor {
return isKclDocument(editor.document)
}
export function isDocumentInWorkspace(document: KclDocument): boolean {
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders) {
return false
}
for (const folder of workspaceFolders) {
if (document.uri.fsPath.startsWith(folder.uri.fsPath)) {
return true
}
}
return false
}
export function isValidExecutable(path: string): boolean {
log.debug('Checking availability of a binary at', path)
const res = spawnSync(path, ['--version'], {
encoding: 'utf8',
env: { ...process.env },
})
const printOutput = res.error ? log.warn : log.info
printOutput(path, '--version:', res)
return res.status === 0
}
/** Sets ['when'](https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts) clause contexts */
export function setContextValue(key: string, value: any): Thenable<void> {
return vscode.commands.executeCommand('setContext', key, value)
}
/**
* Returns a higher-order function that caches the results of invoking the
* underlying async function.
*/
export function memoizeAsync<Ret, TThis, Param extends string>(
func: (this: TThis, arg: Param) => Promise<Ret>
) {
const cache = new Map<string, Ret>()
return async function (this: TThis, arg: Param) {
const cached = cache.get(arg)
if (cached) return cached
const result = await func.call(this, arg)
cache.set(arg, result)
return result
}
}
/** Awaitable wrapper around `child_process.exec` */
export function execute(
command: string,
options: ExecOptions
): Promise<string> {
log.info(`running command: ${command}`)
return new Promise((resolve, reject) => {
exec(command, options, (err, stdout, stderr) => {
if (err) {
log.error(err)
reject(err)
return
}
if (stderr) {
reject(new Error(stderr))
return
}
resolve(stdout.trimEnd())
})
})
}
export function executeDiscoverProject(
command: string,
options: ExecOptions
): Promise<string> {
options = Object.assign({ maxBuffer: 10 * 1024 * 1024 }, options)
log.info(`running command: ${command}`)
return new Promise((resolve, reject) => {
exec(command, options, (err, stdout, _) => {
if (err) {
log.error(err)
reject(err)
return
}
resolve(stdout.trimEnd())
})
})
}
export class LazyOutputChannel implements vscode.OutputChannel {
constructor(name: string) {
this.name = name
}
name: string
_channel: vscode.OutputChannel | undefined
get channel(): vscode.OutputChannel {
if (!this._channel) {
this._channel = vscode.window.createOutputChannel(this.name)
}
return this._channel
}
append(value: string): void {
this.channel.append(value)
}
appendLine(value: string): void {
this.channel.appendLine(value)
}
replace(value: string): void {
this.channel.replace(value)
}
clear(): void {
if (this._channel) {
this._channel.clear()
}
}
show(preserveFocus?: boolean): void
show(column?: vscode.ViewColumn, preserveFocus?: boolean): void
show(column?: any, preserveFocus?: any): void {
this.channel.show(column, preserveFocus)
}
hide(): void {
if (this._channel) {
this._channel.hide()
}
}
dispose(): void {
if (this._channel) {
this._channel.dispose()
}
}
}