diff --git a/vscode-cairo/src/cairols.ts b/vscode-cairo/src/cairols.ts new file mode 100644 index 00000000000..749da93dfd8 --- /dev/null +++ b/vscode-cairo/src/cairols.ts @@ -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("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("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 { + 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) => + !!commands["cairo-language-server"], + ); +} + +async function runStandaloneLs( + scarbPath: undefined | string, + outputChannel: vscode.OutputChannel, + config: vscode.WorkspaceConfiguration, + context: vscode.ExtensionContext, +): Promise { + 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 { + 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 { + 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 { + 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 { + const isScarbEnabled = config.get("cairo1.enableScarb") ?? false; + const scarbPath = await findScarbExecutablePath(config, context); + const configLanguageServerPath = config.get( + "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 => { + 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 { + interface ProvideVirtualFileResponse { + content?: string; + } + + const res = await client.sendRequest( + "vfs/provide", + { + uri: uri.toString(), + }, + ); + + return res.content ?? ""; + } + + onDidChangeEmitter = new vscode.EventEmitter(); + 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; +} diff --git a/vscode-cairo/src/extension.ts b/vscode-cairo/src/extension.ts index cbadd75bad6..dfb729e43be 100644 --- a/vscode-cairo/src/extension.ts +++ b/vscode-cairo/src/extension.ts @@ -1,403 +1,8 @@ import * as vscode from "vscode"; -import { SemanticTokensFeature } from "vscode-languageclient/lib/common/semanticTokens"; -import * as child_process from "child_process"; -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; +import * as lc from "vscode-languageclient/node"; +import { setupLanguageServer } from "./cairols"; -import { - LanguageClient, - LanguageClientOptions, - ServerOptions, -} from "vscode-languageclient/node"; - -let client: LanguageClient; - -// 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("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("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 { - 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) => - !!commands["cairo-language-server"], - ); -} - -async function runStandaloneLs( - scarbPath: undefined | string, - outputChannel: vscode.OutputChannel, - config: vscode.WorkspaceConfiguration, - context: vscode.ExtensionContext, -): Promise { - 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 { - 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 { - 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 { - 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)) - ); -} - -async function setupLanguageServer( - config: vscode.WorkspaceConfiguration, - context: vscode.ExtensionContext, - outputChannel: vscode.OutputChannel, -) { - const isScarbEnabled = config.get("cairo1.enableScarb") ?? false; - const scarbPath = await findScarbExecutablePath(config, context); - const configLanguageServerPath = config.get( - "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: ServerOptions = - async (): Promise => { - 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: LanguageClientOptions = { - documentSelector: [ - { scheme: "file", language: "cairo" }, - { scheme: "vfs", language: "cairo" }, - ], - }; - - client = new 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 { - interface ProvideVirtualFileResponse { - content?: string; - } - - const res = await client.sendRequest( - "vfs/provide", - { - uri: uri.toString(), - }, - ); - - return res.content ?? ""; - } - - onDidChangeEmitter = new vscode.EventEmitter(); - 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(); -} +let client: lc.LanguageClient | undefined; export async function activate(context: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration(); @@ -405,7 +10,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(outputChannel); if (config.get("cairo1.enableLanguageServer")) { - await setupLanguageServer(config, context, outputChannel); + client = await setupLanguageServer(config, context, outputChannel); } else { outputChannel.appendLine( "Language server is not enabled. Use the cairo1.enableLanguageServer config",