Move lsp server to this repo (#5619)
This commit is contained in:
162
rust/kcl-language-server/client/src/bootstrap.ts
Normal file
162
rust/kcl-language-server/client/src/bootstrap.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
74
rust/kcl-language-server/client/src/client.ts
Normal file
74
rust/kcl-language-server/client/src/client.ts
Normal 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 {}
|
||||
}
|
32
rust/kcl-language-server/client/src/commands.ts
Normal file
32
rust/kcl-language-server/client/src/commands.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
293
rust/kcl-language-server/client/src/config.ts
Normal file
293
rust/kcl-language-server/client/src/config.ts
Normal 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
|
||||
}
|
||||
}
|
387
rust/kcl-language-server/client/src/ctx.ts
Normal file
387
rust/kcl-language-server/client/src/ctx.ts
Normal 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
|
24
rust/kcl-language-server/client/src/lsp_ext.ts
Normal file
24
rust/kcl-language-server/client/src/lsp_ext.ts
Normal 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
|
||||
}
|
79
rust/kcl-language-server/client/src/main.ts
Normal file
79
rust/kcl-language-server/client/src/main.ts
Normal 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 },
|
||||
}
|
||||
}
|
21
rust/kcl-language-server/client/src/persistent_state.ts
Normal file
21
rust/kcl-language-server/client/src/persistent_state.ts
Normal 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)
|
||||
}
|
||||
}
|
25
rust/kcl-language-server/client/src/test/runTest.ts
Normal file
25
rust/kcl-language-server/client/src/test/runTest.ts
Normal 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()
|
@ -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)
|
||||
})
|
||||
})
|
33
rust/kcl-language-server/client/src/test/suite/index.ts
Normal file
33
rust/kcl-language-server/client/src/test/suite/index.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
23
rust/kcl-language-server/client/src/undefinable.ts
Normal file
23
rust/kcl-language-server/client/src/undefinable.ts
Normal 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\``)
|
||||
}
|
240
rust/kcl-language-server/client/src/util.ts
Normal file
240
rust/kcl-language-server/client/src/util.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user