Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement multi root workspace folder changes #6

Merged
merged 1 commit into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 17 additions & 151 deletions src/cairols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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<T>(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<boolean> {
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<SetupResult | undefined> {
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<lc.LanguageClient | undefined> {
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 };
Expand All @@ -102,26 +56,6 @@ export async function setupLanguageServer(ctx: Context): Promise<lc.LanguageClie
{},
);

// 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.
const weakClient = new WeakRef(client);

ctx.extension.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(
async () => {
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);
Expand Down Expand Up @@ -160,10 +94,7 @@ export async function setupLanguageServer(ctx: Context): Promise<lc.LanguageClie
);

const restartLS = async () => {
const client = weakClient.deref();
if (client) {
await client.restart();
}
await client.restart();
};

switch (selectedValue) {
Expand Down Expand Up @@ -193,10 +124,10 @@ export async function setupLanguageServer(ctx: Context): Promise<lc.LanguageClie

await client.start();

return client;
return { client, executable: executables[0] };
}

async function findScarbForWorkspaceFolder(
export async function findScarbForWorkspaceFolder(
workspaceFolder: vscode.WorkspaceFolder | undefined,
ctx: Context,
): Promise<Scarb | undefined> {
Expand All @@ -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<LSExecutable | undefined> {
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<LanguageServerExecutableProvider> {
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),
Expand Down
45 changes: 31 additions & 14 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -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: "",
Arcticae marked this conversation as resolved.
Show resolved Hide resolved
});
},
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<void> | undefined {
if (!client) {
return undefined;
}

return client.stop();
}
107 changes: 107 additions & 0 deletions src/extensionManager.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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.",
);
}
}
}
}
Loading