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

VSCode: Separate "main" functions from LanguageClient code #4933

Merged
merged 1 commit into from
Feb 8, 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
396 changes: 396 additions & 0 deletions vscode-cairo/src/cairols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,396 @@
import * as child_process from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as vscode from "vscode";
import { SemanticTokensFeature } from "vscode-languageclient/lib/common/semanticTokens";

import * as lc from "vscode-languageclient/node";

// Tries to find the development version of the language server executable,
// assuming the workspace directory is inside the Cairo repository.
function findDevLanguageServerAt(
path: string,
depth: number,
): string | undefined {
if (depth == 0) {
return undefined;
}
let candidate = path + "/target/release/cairo-language-server";
if (fs.existsSync(candidate)) {
return candidate;
}
candidate = path + "/target/debug/cairo-language-server";
if (fs.existsSync(candidate)) {
return candidate;
}
return findDevLanguageServerAt(path + "/..", depth - 1);
}

function rootPath(context: vscode.ExtensionContext): string {
let rootPath = context.extensionPath;

const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders) {
rootPath = workspaceFolders[0]?.uri.path || rootPath;
}
return rootPath;
}

function isExecutable(path: string): boolean {
try {
fs.accessSync(path, fs.constants.X_OK);
return fs.statSync(path).isFile();
} catch (e) {
return false;
}
}

function replacePathPlaceholders(path: string, root: string): string {
return path
.replace(/\${workspaceFolder}/g, root)
.replace(/\${userHome}/g, process.env["HOME"] ?? "");
}

function findLanguageServerExecutable(
config: vscode.WorkspaceConfiguration,
context: vscode.ExtensionContext,
) {
const root = rootPath(context);
const configPath = config.get<string>("cairo1.languageServerPath");
if (configPath) {
const serverPath = replacePathPlaceholders(configPath, root);
if (!isExecutable(serverPath)) {
return undefined;
}
return serverPath;
}

// TODO(spapini): Use a bundled language server.
return findDevLanguageServerAt(root, 10);
}

async function findExecutableFromPathVar(name: string) {
const envPath = process.env["PATH"] || "";
const envExt = process.env["PATHEXT"] || "";
const pathDirs = envPath
.replace(/["]+/g, "")
.split(path.delimiter)
.filter(Boolean);
const extensions = envExt.split(";");
const candidates: string[] = [];
pathDirs.map((d) =>
extensions.map((ext) => {
candidates.push(path.join(d, name + ext));
}),
);
const isExecutable = (path: string) =>
fs.promises
.access(path, fs.constants.X_OK)
.then(() => path)
.catch(() => undefined);
try {
return await Promise.all(candidates.map(isExecutable)).then((values) =>
values.find((value) => !!value),
);
} catch (e) {
return undefined;
}
}

async function findScarbExecutablePathInAsdfDir() {
if (os.platform() === "win32") {
return undefined;
}

let asdfDataDir = process.env["ASDF_DATA_DIR"];
if (!asdfDataDir) {
const home = process.env["HOME"];
if (!home) {
return undefined;
}
asdfDataDir = path.join(home, ".asdf");
}
const scarbExecutablePath = path.join(asdfDataDir, "shims", "scarb");

try {
await fs.promises.access(scarbExecutablePath, fs.constants.X_OK);
return scarbExecutablePath;
} catch (e) {
return undefined;
}
}

async function findScarbExecutablePath(
config: vscode.WorkspaceConfiguration,
context: vscode.ExtensionContext,
) {
// Check config for scarb path.
const root = rootPath(context);
const configPath = config.get<string>("cairo1.scarbPath");
if (configPath) {
const scarbPath = replacePathPlaceholders(configPath, root);
if (!isExecutable(scarbPath)) {
return undefined;
}
return scarbPath;
}

// Check PATH env var for scarb path.
const envPath = await findExecutableFromPathVar("scarb");
if (envPath) return envPath;

return findScarbExecutablePathInAsdfDir();
}

function notifyScarbMissing(outputChannel: vscode.OutputChannel) {
const errorMessage =
"This is a Scarb project, but could not find Scarb executable on this machine. " +
"Please add Scarb to the PATH environmental variable or set the 'cairo1.scarbPath' configuration " +
"parameter. Otherwise Cairo code analysis will not work.";
vscode.window.showWarningMessage(errorMessage);
outputChannel.appendLine(errorMessage);
}

async function listScarbCommandsOutput(
scarbPath: undefined | string,
context: vscode.ExtensionContext,
) {
if (!scarbPath) {
return undefined;
}
const child = child_process.spawn(scarbPath, ["--json", "commands"], {
stdio: "pipe",
cwd: rootPath(context),
});
let stdout = "";
for await (const chunk of child.stdout) {
stdout += chunk;
}
return stdout;
}

async function isScarbLsPresent(
scarbPath: undefined | string,
context: vscode.ExtensionContext,
): Promise<boolean> {
if (!scarbPath) {
return false;
}
const scarbOutput = await listScarbCommandsOutput(scarbPath, context);
if (!scarbOutput) return false;
return scarbOutput
.split("\n")
.map((v) => v.trim())
.filter((v) => !!v)
.map((v) => JSON.parse(v))
.some(
(commands: Record<string, unknown>) =>
!!commands["cairo-language-server"],
);
}

async function runStandaloneLs(
scarbPath: undefined | string,
outputChannel: vscode.OutputChannel,
config: vscode.WorkspaceConfiguration,
context: vscode.ExtensionContext,
): Promise<undefined | child_process.ChildProcessWithoutNullStreams> {
const executable = findLanguageServerExecutable(config, context);
if (!executable) {
outputChannel.appendLine(
"Cairo language server was not found. Make sure cairo-lang-server is " +
"installed and that the configuration 'cairo1.languageServerPath' is correct.",
);
return;
}
outputChannel.appendLine("Cairo language server running from: " + executable);
return child_process.spawn(executable, {
env: { SCARB: scarbPath },
});
}

async function runScarbLs(
scarbPath: undefined | string,
outputChannel: vscode.OutputChannel,
context: vscode.ExtensionContext,
): Promise<undefined | child_process.ChildProcessWithoutNullStreams> {
if (!scarbPath) {
return;
}
outputChannel.appendLine(
"Cairo language server running from Scarb at: " + scarbPath,
);
return child_process.spawn(scarbPath, ["cairo-language-server"], {
cwd: rootPath(context),
});
}

enum ServerType {
Standalone,
Scarb,
}

async function getServerType(
isScarbEnabled: boolean,
scarbPath: string | undefined,
configLanguageServerPath: string | undefined,
context: vscode.ExtensionContext,
) {
if (!isScarbEnabled) return ServerType.Standalone;
if (!(await isScarbProject()) && !!configLanguageServerPath) {
// If Scarb manifest is missing, and Cairo-LS path is explicit.
return ServerType.Standalone;
}
if (await isScarbLsPresent(scarbPath, context)) return ServerType.Scarb;
return ServerType.Standalone;
}

async function isScarbProjectAt(path: string, depth: number): Promise<boolean> {
if (depth == 0) return false;
const isFile = await fs.promises
.access(path + "/Scarb.toml", fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (isFile) return true;
return isScarbProjectAt(path + "/..", depth - 1);
}

async function isScarbProject(): Promise<boolean> {
const depth = 20;
const workspaceFolders = vscode.workspace.workspaceFolders;
if (
!!workspaceFolders?.[0] &&
(await isScarbProjectAt(path.dirname(workspaceFolders[0].uri.path), depth))
)
return true;
const editor = vscode.window.activeTextEditor;
return (
!!editor &&
(await isScarbProjectAt(path.dirname(editor.document.uri.path), depth))
);
}

export async function setupLanguageServer(
config: vscode.WorkspaceConfiguration,
context: vscode.ExtensionContext,
outputChannel: vscode.OutputChannel,
): Promise<lc.LanguageClient> {
const isScarbEnabled = config.get<boolean>("cairo1.enableScarb") ?? false;
const scarbPath = await findScarbExecutablePath(config, context);
const configLanguageServerPath = config.get<string>(
"cairo1.languageServerPath",
);

if (!isScarbEnabled) {
outputChannel.appendLine("Use of Scarb is disabled as of configuration.");
} else if (!scarbPath) {
outputChannel.appendLine("Failed to find Scarb binary path.");
} else {
outputChannel.appendLine("Using Scarb binary from: " + scarbPath);
}
const serverOptions: lc.ServerOptions =
async (): Promise<child_process.ChildProcessWithoutNullStreams> => {
const serverType = await getServerType(
isScarbEnabled,
scarbPath,
configLanguageServerPath,
context,
);
let child;
if (serverType === ServerType.Scarb) {
child = await runScarbLs(scarbPath, outputChannel, context);
} else {
child = await runStandaloneLs(
scarbPath,
outputChannel,
config,
context,
);
}
if (!child) {
outputChannel.appendLine("Failed to start Cairo language server.");
throw new Error("Failed to start Cairo language server.");
}
// Forward stderr to vscode logs.
child.stderr.on("data", (data: Buffer) => {
outputChannel.appendLine("Server stderr> " + data.toString());
});
child.on("exit", (code, signal) => {
outputChannel.appendLine(
"Cairo language server exited with code " +
code +
" and signal" +
signal,
);
});

// Create a resolved promise with the child process.
return child;
};

const clientOptions: lc.LanguageClientOptions = {
documentSelector: [
{ scheme: "file", language: "cairo" },
{ scheme: "vfs", language: "cairo" },
],
};

const client = new lc.LanguageClient(
"cairoLanguageServer",
"Cairo Language Server",
serverOptions,
clientOptions,
);

client.registerFeature(new SemanticTokensFeature(client));

const myProvider = new (class implements vscode.TextDocumentContentProvider {
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
interface ProvideVirtualFileResponse {
content?: string;
}

const res = await client.sendRequest<ProvideVirtualFileResponse>(
"vfs/provide",
{
uri: uri.toString(),
},
);

return res.content ?? "";
}

onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
onDidChange = this.onDidChangeEmitter.event;
})();
client.onNotification("vfs/update", (param) => {
myProvider.onDidChangeEmitter.fire(param.uri);
});
vscode.workspace.registerTextDocumentContentProvider("vfs", myProvider);

client.onNotification("scarb/could-not-find-scarb-executable", () =>
notifyScarbMissing(outputChannel),
);

client.onNotification("scarb/resolving-start", () => {
vscode.window.withProgress(
{
title: "Scarb is resolving the project...",
location: vscode.ProgressLocation.Notification,
cancellable: false,
},
async () => {
return new Promise((resolve) => {
client.onNotification("scarb/resolving-finish", () => {
resolve(null);
});
});
},
);
});

await client.start();

return client;
}
Loading
Loading