Skip to content

Commit

Permalink
VSCode: Separate "main" functions from LanguageClient code (starkware…
Browse files Browse the repository at this point in the history
  • Loading branch information
mkaput authored and delaaxe committed Feb 8, 2024
1 parent 561e36e commit e20f185
Show file tree
Hide file tree
Showing 2 changed files with 400 additions and 399 deletions.
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

0 comments on commit e20f185

Please sign in to comment.