diff --git a/src/cairols.ts b/src/cairols.ts index 8f03f18..9fe19bc 100644 --- a/src/cairols.ts +++ b/src/cairols.ts @@ -4,18 +4,13 @@ import { SemanticTokensFeature } from "vscode-languageclient/lib/common/semantic import * as lc from "vscode-languageclient/node"; import { Context } from "./context"; import { Scarb } from "./scarb"; -import { isScarbProject } from "./scarbProject"; -import { StandaloneLS } from "./standalonels"; import { registerMacroExpandProvider, registerVfsProvider, registerViewAnalyzedCratesProvider, } from "./textDocumentProviders"; -import assert, { AssertionError } from "node:assert"; - -export interface LanguageServerExecutableProvider { - languageServerExecutable(): lc.Executable; -} +import { executablesEqual, getLSExecutables, LSExecutable } from "./lsExecutable"; +import assert from "node:assert"; function notifyScarbMissing(ctx: Context) { const errorMessage = @@ -26,71 +21,30 @@ function notifyScarbMissing(ctx: Context) { ctx.log.error(errorMessage); } -// Deep equality (based on native nodejs assertion), without throwing an error -function safeStrictDeepEqual(a: T, b: T): boolean { - try { - assert.deepStrictEqual(a, b); - return true; - } catch (err) { - if (err instanceof AssertionError) { - return false; - } - throw err; - } -} - -function areExecutablesEqual(a: lc.Executable, b: lc.Executable): boolean { - return ( - a.command === b.command && - safeStrictDeepEqual(a.args, b.args) && - safeStrictDeepEqual(a.options?.env, b.options?.env) - ); +export interface SetupResult { + client: lc.LanguageClient; + executable: LSExecutable; } -async function allFoldersHaveSameLSProvider( - ctx: Context, - executables: LSExecutable[], -): Promise { - if (executables.length < 2) { - return true; - } - - // If every executable is scarb based, check if the versions match additionally - if (executables.every((v) => !!v.scarb)) { - const versions = await Promise.all(executables.map((v) => v.scarb!.getVersion(ctx))); - if (!versions.every((x) => versions[0] === x)) { - return false; - } +export async function setupLanguageServer(ctx: Context): Promise { + const executables = await getLSExecutables(vscode.workspace.workspaceFolders || [], ctx); + if (executables.length === 0) { + return; } - return executables.every((x) => areExecutablesEqual(executables[0]!.run, x.run)); -} - -export async function setupLanguageServer(ctx: Context): Promise { - const executables = ( - await Promise.all( - (vscode.workspace.workspaceFolders || []).map((workspaceFolder) => - getLanguageServerExecutable(workspaceFolder, ctx), - ), - ) - ).filter((x) => !!x); - - const sameProvider = await allFoldersHaveSameLSProvider(ctx, executables); - if (!sameProvider) { + const sameCommand = await executablesEqual(executables); + if (!sameCommand) { await vscode.window.showErrorMessage( - "Using multiple Scarb versions in one workspace are not supported.", + "Using multiple Scarb versions in one workspace is not supported.", ); return; } // First one is good as any of them since they should be all the same at this point - const lsExecutable = executables[0]; - - assert(lsExecutable, "Failed to start Cairo LS"); + assert(executables[0], "executable should be present at this point"); + const [{ preparedInvocation: run, scarb }] = executables; - const { run, scarb } = lsExecutable; setupEnv(run, ctx); - ctx.log.debug(`using CairoLS: ${quoteServerExecutable(run)}`); const serverOptions = { run, debug: run }; @@ -102,26 +56,6 @@ export async function setupLanguageServer(ctx: Context): Promise { - const client = weakClient.deref(); - if (client != undefined) { - await client.sendNotification(lc.DidChangeConfigurationNotification.type, { - settings: "", - }); - } - }, - null, - ctx.extension.subscriptions, - ), - ); - client.registerFeature(new SemanticTokensFeature(client)); registerVfsProvider(client, ctx); @@ -160,10 +94,7 @@ export async function setupLanguageServer(ctx: Context): Promise { - const client = weakClient.deref(); - if (client) { - await client.restart(); - } + await client.restart(); }; switch (selectedValue) { @@ -193,10 +124,10 @@ export async function setupLanguageServer(ctx: Context): Promise { @@ -216,71 +147,6 @@ async function findScarbForWorkspaceFolder( } } -interface LSExecutable { - run: lc.Executable; - scarb: Scarb | undefined; -} - -async function getLanguageServerExecutable( - workspaceFolder: vscode.WorkspaceFolder | undefined, - ctx: Context, -): Promise { - const scarb = await findScarbForWorkspaceFolder(workspaceFolder, ctx); - try { - const provider = await determineLanguageServerExecutableProvider(workspaceFolder, scarb, ctx); - return { run: provider.languageServerExecutable(), scarb }; - } catch (e) { - ctx.log.error(`${e}`); - } - return undefined; -} - -async function determineLanguageServerExecutableProvider( - workspaceFolder: vscode.WorkspaceFolder | undefined, - scarb: Scarb | undefined, - ctx: Context, -): Promise { - const log = ctx.log.span("determineLanguageServerExecutableProvider"); - const standalone = () => StandaloneLS.find(workspaceFolder, scarb, ctx); - - if (!scarb) { - log.trace("Scarb is missing"); - return await standalone(); - } - - if (await isScarbProject()) { - log.trace("this is a Scarb project"); - - if (!ctx.config.get("preferScarbLanguageServer", true)) { - log.trace("`preferScarbLanguageServer` is false, using standalone LS"); - return await standalone(); - } - - if (await scarb.hasCairoLS(ctx)) { - log.trace("using Scarb LS"); - return scarb; - } - - log.trace("Scarb has no LS extension, falling back to standalone"); - return await standalone(); - } else { - log.trace("this is *not* a Scarb project, looking for standalone LS"); - - try { - return await standalone(); - } catch (e) { - log.trace("could not find standalone LS, trying Scarb LS"); - if (await scarb.hasCairoLS(ctx)) { - log.trace("using Scarb LS"); - return scarb; - } - - log.trace("could not find standalone LS and Scarb has no LS extension, will error out"); - throw e; - } - } -} - function setupEnv(serverExecutable: lc.Executable, ctx: Context) { const logEnv = { CAIRO_LS_LOG: buildEnvFilter(ctx), diff --git a/src/extension.ts b/src/extension.ts index e216513..09ed73d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,27 +1,44 @@ import * as vscode from "vscode"; import * as lc from "vscode-languageclient/node"; -import { setupLanguageServer } from "./cairols"; import { Context } from "./context"; - -let client: lc.LanguageClient | undefined; +import { CairoExtensionManager } from "./extensionManager"; export async function activate(extensionContext: vscode.ExtensionContext) { const ctx = Context.create(extensionContext); if (ctx.config.get("enableLanguageServer")) { - client = await setupLanguageServer(ctx); + const extensionManager = CairoExtensionManager.fromContext(ctx); + extensionManager.tryStartClient(); + + // Notify the server when the client configuration changes. + // CairoLS pulls configuration properties it is interested in by itself, so it + // is unnecessary to attach any details in the notification payload. + ctx.extension.subscriptions.push( + vscode.workspace.onDidChangeConfiguration( + async () => { + await extensionManager + .getClient() + ?.sendNotification(lc.DidChangeConfigurationNotification.type, { + settings: "", + }); + }, + null, + ctx.extension.subscriptions, + ), + ); + + // React to workspace folders changes (additions, deletions). + ctx.extension.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders( + async (event) => await extensionManager.handleDidChangeWorkspaceFolders(event), + null, + ctx.extension.subscriptions, + ), + ); + ctx.extension.subscriptions.push(extensionManager); + ctx.statusBar.setup(extensionManager.getClient()); } else { ctx.log.warn("language server is disabled"); ctx.log.warn("note: set `cairo1.enableLanguageServer` to `true` to enable it"); } - - await ctx.statusBar.setup(client); -} - -export function deactivate(): Thenable | undefined { - if (!client) { - return undefined; - } - - return client.stop(); } diff --git a/src/extensionManager.ts b/src/extensionManager.ts new file mode 100644 index 0000000..be351ee --- /dev/null +++ b/src/extensionManager.ts @@ -0,0 +1,107 @@ +import * as lc from "vscode-languageclient/node"; +import * as vscode from "vscode"; +import { setupLanguageServer } from "./cairols"; +import { executablesEqual, getLSExecutables, LSExecutable } from "./lsExecutable"; +import { Context } from "./context"; +import assert from "node:assert"; + +/** + * There is only one {@link lc.LanguageClient} instance active at one time, which this class manages. + * There's a specific reason for it, if we have multiple instances for multiple workspaces, + * upon restart we don't have the information about file <-> client association (i.e. for files from deps). + * This is only one example, there are potentially many more corner cases like this one. + * + * Thus, the client/server instance is effectively a singleton. + */ +export class CairoExtensionManager { + private constructor( + public readonly context: Context, + private client: lc.LanguageClient | undefined, + private runningExecutable: LSExecutable | undefined, + ) {} + + public static fromContext(context: Context): CairoExtensionManager { + return new CairoExtensionManager(context, undefined, undefined); + } + + /** + * Starts the server, sets up the client and returns status. + */ + public async tryStartClient(): Promise { + if (this.runningExecutable) { + return false; + } + + const setupResult = await setupLanguageServer(this.context); + if (!setupResult) { + return false; + } + const { client, executable } = setupResult; + this.client = client; + this.runningExecutable = executable; + return true; + } + + public async stopClient() { + await this.client?.stop(); + this.client = undefined; + this.runningExecutable = undefined; + } + + public getClient(): lc.LanguageClient | undefined { + return this.client; + } + + public async handleDidChangeWorkspaceFolders(event: vscode.WorkspaceFoldersChangeEvent) { + if (event.added.length > 0) { + await this.handleWorkspaceFoldersAdded(event.added); + } + + if (event.removed.length > 0) { + this.handleWorkspaceFoldersRemoved(); + } + } + + public dispose() { + this.stopClient(); + } + + private handleWorkspaceFoldersRemoved() { + this.tryStartClient(); + } + + private async handleWorkspaceFoldersAdded(added: readonly vscode.WorkspaceFolder[]) { + const ctx = this.context; + + const newExecutables = await getLSExecutables(added, ctx); + if (newExecutables.length === 0) { + return; + } + + // Check if new ones are of same provider. + const newExecutablesHaveSameProvider = await executablesEqual(newExecutables); + + if (newExecutablesHaveSameProvider) { + // In case we weren't running anything previously, we can start up a new server. + const started = await this.tryStartClient(); + if (started) { + return; + } + assert(this.runningExecutable, "An executable should be present by this point"); // We disallow this by trying to run the start procedure beforehand. + + const consistentWithPreviousLS = await executablesEqual([ + ...newExecutables, + this.runningExecutable, + ]); + + // If it's not consistent, we need to stop LS and show an error, it's better to show no analysis results than broken ones. + // For example - a person can turn on a project with incompatible corelib version. + if (!consistentWithPreviousLS) { + await this.stopClient(); + vscode.window.showErrorMessage( + "Analysis disabled: the added folder does not have the same version of Scarb as the rest of the workspace. Please remove this folder from the workspace, or update its Scarb version to match the others.", + ); + } + } + } +} diff --git a/src/lsExecutable.ts b/src/lsExecutable.ts new file mode 100644 index 0000000..66555e9 --- /dev/null +++ b/src/lsExecutable.ts @@ -0,0 +1,127 @@ +import * as lc from "vscode-languageclient/node"; +import * as vscode from "vscode"; + +import { Scarb } from "./scarb"; +import { findScarbForWorkspaceFolder } from "./cairols"; +import { Context } from "./context"; +import { isScarbProject } from "./scarbProject"; +import { StandaloneLS } from "./standalonels"; + +export async function getLSExecutables( + workspaceFolders: readonly vscode.WorkspaceFolder[], + ctx: Context, +): Promise { + return ( + await Promise.all( + workspaceFolders.map((workspaceFolder) => LSExecutable.tryFind(workspaceFolder, ctx)), + ) + ).filter((x) => !!x); +} + +export async function executablesEqual(executables: LSExecutable[]): Promise { + if (executables.length < 2) { + return true; + } + + for (const executable of executables) { + if (!(await executables[0]!.equals(executable))) { + return false; + } + } + return true; +} + +export class LSExecutable { + private constructor( + public readonly workspaceFolder: vscode.WorkspaceFolder | undefined, + public readonly preparedInvocation: lc.Executable, + public readonly scarb: Scarb | undefined, + private readonly context: Context, + ) {} + + public static async tryFind( + workspaceFolder: vscode.WorkspaceFolder | undefined, + ctx: Context, + ): Promise { + const scarb = await findScarbForWorkspaceFolder(workspaceFolder, ctx); + try { + const provider = await determineLanguageServerExecutableProvider(workspaceFolder, scarb, ctx); + const preparedInvocation = provider.languageServerExecutable(); + return new LSExecutable(workspaceFolder, preparedInvocation, scarb, ctx); + } catch (e) { + ctx.log.error(`${e}`); + } + return undefined; + } + + public async equals(other: LSExecutable): Promise { + const commandsEqual = this.preparedInvocation.command === other.preparedInvocation.command; + + const argsEqual = + this.preparedInvocation.args === other.preparedInvocation.args || + (Array.isArray(this.preparedInvocation.args) && + Array.isArray(other.preparedInvocation.args) && + this.preparedInvocation.args.length === other.preparedInvocation.args.length && + this.preparedInvocation.args.every( + (value, index) => value === other.preparedInvocation.args![index], + )); + + // Also check the scarb versions, if there are any present + const scarbVersionsEqual = + (await this.scarb?.getVersion(this.context)) === + (await other.scarb?.getVersion(this.context)); + + return commandsEqual && argsEqual && scarbVersionsEqual; + } +} + +// TODO(6740): Get rid of this interface, it's an extra level of abstraction we don't need (we call it immediataly anyways). +export interface LanguageServerExecutableProvider { + languageServerExecutable(): lc.Executable; +} + +export async function determineLanguageServerExecutableProvider( + workspaceFolder: vscode.WorkspaceFolder | undefined, + scarb: Scarb | undefined, + ctx: Context, +): Promise { + const log = ctx.log.span("determineLanguageServerExecutableProvider"); + const standalone = () => StandaloneLS.find(workspaceFolder, scarb, ctx); + + if (!scarb) { + log.trace("Scarb is missing"); + return await standalone(); + } + + if (await isScarbProject()) { + log.trace("this is a Scarb project"); + + if (!ctx.config.get("preferScarbLanguageServer", true)) { + log.trace("`preferScarbLanguageServer` is false, using standalone LS"); + return await standalone(); + } + + if (await scarb.hasCairoLS(ctx)) { + log.trace("using Scarb LS"); + return scarb; + } + + log.trace("Scarb has no LS extension, falling back to standalone"); + return await standalone(); + } else { + log.trace("this is *not* a Scarb project, looking for standalone LS"); + + try { + return await standalone(); + } catch (e) { + log.trace("could not find standalone LS, trying Scarb LS"); + if (await scarb.hasCairoLS(ctx)) { + log.trace("using Scarb LS"); + return scarb; + } + + log.trace("could not find standalone LS and Scarb has no LS extension, will error out"); + throw e; + } + } +} diff --git a/src/scarb.ts b/src/scarb.ts index 8d16051..576d4ea 100644 --- a/src/scarb.ts +++ b/src/scarb.ts @@ -1,7 +1,7 @@ import { spawn } from "child_process"; import * as vscode from "vscode"; import * as lc from "vscode-languageclient/node"; -import type { LanguageServerExecutableProvider } from "./cairols"; +import type { LanguageServerExecutableProvider } from "./lsExecutable"; import type { Context } from "./context"; import { checkTool, findToolInAsdf, findToolInPath } from "./toolchain"; @@ -18,6 +18,8 @@ export class Scarb implements LanguageServerExecutableProvider { * hence we associate workspace folder reference with the Scarb instance. */ public readonly workspaceFolder?: vscode.WorkspaceFolder | undefined, + + private version?: string | undefined, ) {} /** @@ -88,8 +90,8 @@ export class Scarb implements LanguageServerExecutableProvider { } public async getVersion(ctx: Context): Promise { - const output = await this.execWithOutput(["--version"], ctx); - return output.trim(); + this.version ??= (await this.execWithOutput(["--version"], ctx)).trim(); + return this.version; } public async cacheClean(ctx: Context): Promise { diff --git a/src/standalonels.ts b/src/standalonels.ts index b038851..f085027 100644 --- a/src/standalonels.ts +++ b/src/standalonels.ts @@ -1,7 +1,7 @@ import * as path from "path"; import * as vscode from "vscode"; import * as lc from "vscode-languageclient/node"; -import type { LanguageServerExecutableProvider } from "./cairols"; +import type { LanguageServerExecutableProvider } from "./lsExecutable"; import type { Context } from "./context"; import type { Scarb } from "./scarb"; import { checkTool, findToolAtWithExtension } from "./toolchain"; diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..e69de29