Skip to content

Commit c15335c

Browse files
committed
Auto merge of #14307 - davidbarsky:davidbarsky/add-cargo-style-project-discovery-for-buck-and-bazel-sickos, r=Veykril
Add Cargo-style project discovery for Buck and Bazel Users This feature requires the user to add a command that generates a `rust-project.json` from a set of files. Project discovery can be invoked in two ways: 1. At extension activation time, which includes the generated `rust-project.json` as part of the linkedProjects argument in `InitializeParams`. 2. Through a new command titled "rust-analyzer: Add current file to workspace", which makes use of a new, rust-analyzer-specific LSP request that adds the workspace without erasing any existing workspaces. Note that there is no mechanism to _remove_ workspaces other than "quit the rust-analyzer server". Few notes: - I think that the command-running functionality _could_ merit being placed into its own extension (and expose it via extension contribution points) to provide build-system idiomatic progress reporting and status handling, but I haven't (yet) made an extension that does this nor does Buck expose this sort of functionality. - This approach would _just work_ for Bazel. I'll try and get the tool that's responsible for Buck integration open-sourced soon. - On the testing side of things, I've used this in around my employer's Buck-powered monorepo and it's a nice experience. That being said, I can't think of an open-source repository where this can be tested in public, so you might need to trust me on this one. I'd love to get feedback on: - Naming of LSP extensions/new commands. I'm not too pleased with how "rust-analyzer: Add current file to workspace" is named, in that it's creating a _new_ workspace. I think that this command being added should be gated on `rust-analyzer.discoverProjectCommand` on being set, so I can add this in sequent commits. - My Typescript. It's not particularly good. - Suggestions on handling folders with _both_ Cargo and non-Cargo build systems and if I make activation a bit better. (I previously tried to add this functionality entirely within rust-analyzer-the-LSP server itself, but matklad was right—an extension side approach is much, much easier.)
2 parents ad91622 + 6e7bc07 commit c15335c

File tree

11 files changed

+257
-12
lines changed

11 files changed

+257
-12
lines changed

crates/rust-analyzer/src/config.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,6 @@ config_data! {
272272
/// The warnings will be indicated by a blue squiggly underline in code
273273
/// and a blue icon in the `Problems Panel`.
274274
diagnostics_warningsAsInfo: Vec<String> = "[]",
275-
276275
/// These directories will be ignored by rust-analyzer. They are
277276
/// relative to the workspace root, and globs are not supported. You may
278277
/// also need to add the folders to Code's `files.watcherExclude`.
@@ -895,6 +894,15 @@ impl Config {
895894
}
896895
}
897896

897+
pub fn add_linked_projects(&mut self, linked_projects: Vec<ProjectJsonData>) {
898+
let mut linked_projects = linked_projects
899+
.into_iter()
900+
.map(ManifestOrProjectJson::ProjectJson)
901+
.collect::<Vec<ManifestOrProjectJson>>();
902+
903+
self.data.linkedProjects.append(&mut linked_projects);
904+
}
905+
898906
pub fn did_save_text_document_dynamic_registration(&self) -> bool {
899907
let caps = try_or_def!(self.caps.text_document.as_ref()?.synchronization.clone()?);
900908
caps.did_save == Some(true) && caps.dynamic_registration == Some(true)

crates/rust-analyzer/src/handlers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ use crate::{
4646
pub(crate) fn handle_workspace_reload(state: &mut GlobalState, _: ()) -> Result<()> {
4747
state.proc_macro_clients.clear();
4848
state.proc_macro_changed = false;
49+
4950
state.fetch_workspaces_queue.request_op("reload workspace request".to_string());
5051
state.fetch_build_data_queue.request_op("reload workspace request".to_string());
5152
Ok(())

editors/code/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@
199199
"title": "Reload workspace",
200200
"category": "rust-analyzer"
201201
},
202+
{
203+
"command": "rust-analyzer.addProject",
204+
"title": "Add current file's crate to workspace",
205+
"category": "rust-analyzer"
206+
},
202207
{
203208
"command": "rust-analyzer.reload",
204209
"title": "Restart server",
@@ -428,6 +433,17 @@
428433
"default": false,
429434
"type": "boolean"
430435
},
436+
"rust-analyzer.discoverProjectCommand": {
437+
"markdownDescription": "Sets the command that rust-analyzer uses to generate `rust-project.json` files. This command should only be used\n if a build system like Buck or Bazel is also in use. The command must accept files as arguments and return \n a rust-project.json over stdout.",
438+
"default": null,
439+
"type": [
440+
"null",
441+
"array"
442+
],
443+
"items": {
444+
"type": "string"
445+
}
446+
},
431447
"$generated-start": {},
432448
"rust-analyzer.assist.emitMustUse": {
433449
"markdownDescription": "Whether to insert #[must_use] when generating `as_` methods\nfor enum variants.",

editors/code/src/client.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as Is from "vscode-languageclient/lib/common/utils/is";
66
import { assert } from "./util";
77
import * as diagnostics from "./diagnostics";
88
import { WorkspaceEdit } from "vscode";
9-
import { Config, substituteVSCodeVariables } from "./config";
9+
import { Config, prepareVSCodeConfig } from "./config";
1010
import { randomUUID } from "crypto";
1111

1212
export interface Env {
@@ -95,7 +95,16 @@ export async function createClient(
9595
const resp = await next(params, token);
9696
if (resp && Array.isArray(resp)) {
9797
return resp.map((val) => {
98-
return substituteVSCodeVariables(val);
98+
return prepareVSCodeConfig(val, (key, cfg) => {
99+
// we only want to set discovered workspaces on the right key
100+
// and if a workspace has been discovered.
101+
if (
102+
key === "linkedProjects" &&
103+
config.discoveredWorkspaces.length > 0
104+
) {
105+
cfg[key] = config.discoveredWorkspaces;
106+
}
107+
});
99108
});
100109
} else {
101110
return resp;

editors/code/src/commands.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as lc from "vscode-languageclient";
33
import * as ra from "./lsp_ext";
44
import * as path from "path";
55

6-
import { Ctx, Cmd, CtxInit } from "./ctx";
6+
import { Ctx, Cmd, CtxInit, discoverWorkspace } from "./ctx";
77
import { applySnippetWorkspaceEdit, applySnippetTextEdits } from "./snippets";
88
import { spawnSync } from "child_process";
99
import { RunnableQuickPick, selectRunnable, createTask, createArgs } from "./run";
@@ -749,6 +749,33 @@ export function reloadWorkspace(ctx: CtxInit): Cmd {
749749
return async () => ctx.client.sendRequest(ra.reloadWorkspace);
750750
}
751751

752+
export function addProject(ctx: CtxInit): Cmd {
753+
return async () => {
754+
const discoverProjectCommand = ctx.config.discoverProjectCommand;
755+
if (!discoverProjectCommand) {
756+
return;
757+
}
758+
759+
const workspaces: JsonProject[] = await Promise.all(
760+
vscode.workspace.workspaceFolders!.map(async (folder): Promise<JsonProject> => {
761+
const rustDocuments = vscode.workspace.textDocuments.filter(isRustDocument);
762+
return discoverWorkspace(rustDocuments, discoverProjectCommand, {
763+
cwd: folder.uri.fsPath,
764+
});
765+
})
766+
);
767+
768+
ctx.addToDiscoveredWorkspaces(workspaces);
769+
770+
// this is a workaround to avoid needing writing the `rust-project.json` into
771+
// a workspace-level VS Code-specific settings folder. We'd like to keep the
772+
// `rust-project.json` entirely in-memory.
773+
await ctx.client?.sendNotification(lc.DidChangeConfigurationNotification.type, {
774+
settings: "",
775+
});
776+
};
777+
}
778+
752779
async function showReferencesImpl(
753780
client: LanguageClient | undefined,
754781
uri: string,

editors/code/src/config.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class Config {
3434

3535
constructor(ctx: vscode.ExtensionContext) {
3636
this.globalStorageUri = ctx.globalStorageUri;
37+
this.discoveredWorkspaces = [];
3738
vscode.workspace.onDidChangeConfiguration(
3839
this.onDidChangeConfiguration,
3940
this,
@@ -55,6 +56,8 @@ export class Config {
5556
log.info("Using configuration", Object.fromEntries(cfg));
5657
}
5758

59+
public discoveredWorkspaces: JsonProject[];
60+
5861
private async onDidChangeConfiguration(event: vscode.ConfigurationChangeEvent) {
5962
this.refreshLogging();
6063

@@ -191,7 +194,7 @@ export class Config {
191194
* So this getter handles this quirk by not requiring the caller to use postfix `!`
192195
*/
193196
private get<T>(path: string): T | undefined {
194-
return substituteVSCodeVariables(this.cfg.get<T>(path));
197+
return prepareVSCodeConfig(this.cfg.get<T>(path));
195198
}
196199

197200
get serverPath() {
@@ -214,6 +217,10 @@ export class Config {
214217
return this.get<boolean>("trace.extension");
215218
}
216219

220+
get discoverProjectCommand() {
221+
return this.get<string[] | undefined>("discoverProjectCommand");
222+
}
223+
217224
get cargoRunner() {
218225
return this.get<string | undefined>("cargoRunner");
219226
}
@@ -280,18 +287,32 @@ export class Config {
280287
}
281288
}
282289

283-
export function substituteVSCodeVariables<T>(resp: T): T {
290+
// the optional `cb?` parameter is meant to be used to add additional
291+
// key/value pairs to the VS Code configuration. This needed for, e.g.,
292+
// including a `rust-project.json` into the `linkedProjects` key as part
293+
// of the configuration/InitializationParams _without_ causing VS Code
294+
// configuration to be written out to workspace-level settings. This is
295+
// undesirable behavior because rust-project.json files can be tens of
296+
// thousands of lines of JSON, most of which is not meant for humans
297+
// to interact with.
298+
export function prepareVSCodeConfig<T>(
299+
resp: T,
300+
cb?: (key: Extract<keyof T, string>, res: { [key: string]: any }) => void
301+
): T {
284302
if (Is.string(resp)) {
285303
return substituteVSCodeVariableInString(resp) as T;
286304
} else if (resp && Is.array<any>(resp)) {
287305
return resp.map((val) => {
288-
return substituteVSCodeVariables(val);
306+
return prepareVSCodeConfig(val);
289307
}) as T;
290308
} else if (resp && typeof resp === "object") {
291309
const res: { [key: string]: any } = {};
292310
for (const key in resp) {
293311
const val = resp[key];
294-
res[key] = substituteVSCodeVariables(val);
312+
res[key] = prepareVSCodeConfig(val);
313+
if (cb) {
314+
cb(key, res);
315+
}
295316
}
296317
return res as T;
297318
}

editors/code/src/ctx.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ import * as vscode from "vscode";
22
import * as lc from "vscode-languageclient/node";
33
import * as ra from "./lsp_ext";
44

5-
import { Config, substituteVSCodeVariables } from "./config";
5+
import { Config, prepareVSCodeConfig } from "./config";
66
import { createClient } from "./client";
7-
import { isRustDocument, isRustEditor, LazyOutputChannel, log, RustEditor } from "./util";
7+
import {
8+
executeDiscoverProject,
9+
isRustDocument,
10+
isRustEditor,
11+
LazyOutputChannel,
12+
log,
13+
RustEditor,
14+
} from "./util";
815
import { ServerStatusParams } from "./lsp_ext";
916
import { PersistentState } from "./persistent_state";
1017
import { bootstrap } from "./bootstrap";
18+
import { ExecOptions } from "child_process";
1119

1220
// We only support local folders, not eg. Live Share (`vlsl:` scheme), so don't activate if
1321
// only those are in use. We use "Empty" to represent these scenarios
@@ -41,6 +49,17 @@ export function fetchWorkspace(): Workspace {
4149
: { kind: "Workspace Folder" };
4250
}
4351

52+
export async function discoverWorkspace(
53+
files: readonly vscode.TextDocument[],
54+
command: string[],
55+
options: ExecOptions
56+
): Promise<JsonProject> {
57+
const paths = files.map((f) => `"${f.uri.fsPath}"`).join(" ");
58+
const joinedCommand = command.join(" ");
59+
const data = await executeDiscoverProject(`${joinedCommand} ${paths}`, options);
60+
return JSON.parse(data) as JsonProject;
61+
}
62+
4463
export type CommandFactory = {
4564
enabled: (ctx: CtxInit) => Cmd;
4665
disabled?: (ctx: Ctx) => Cmd;
@@ -52,7 +71,7 @@ export type CtxInit = Ctx & {
5271

5372
export class Ctx {
5473
readonly statusBar: vscode.StatusBarItem;
55-
readonly config: Config;
74+
config: Config;
5675
readonly workspace: Workspace;
5776

5877
private _client: lc.LanguageClient | undefined;
@@ -169,7 +188,30 @@ export class Ctx {
169188
};
170189
}
171190

172-
const initializationOptions = substituteVSCodeVariables(rawInitializationOptions);
191+
const discoverProjectCommand = this.config.discoverProjectCommand;
192+
if (discoverProjectCommand) {
193+
const workspaces: JsonProject[] = await Promise.all(
194+
vscode.workspace.workspaceFolders!.map(async (folder): Promise<JsonProject> => {
195+
const rustDocuments = vscode.workspace.textDocuments.filter(isRustDocument);
196+
return discoverWorkspace(rustDocuments, discoverProjectCommand, {
197+
cwd: folder.uri.fsPath,
198+
});
199+
})
200+
);
201+
202+
this.addToDiscoveredWorkspaces(workspaces);
203+
}
204+
205+
const initializationOptions = prepareVSCodeConfig(
206+
rawInitializationOptions,
207+
(key, obj) => {
208+
// we only want to set discovered workspaces on the right key
209+
// and if a workspace has been discovered.
210+
if (key === "linkedProjects" && this.config.discoveredWorkspaces.length > 0) {
211+
obj["linkedProjects"] = this.config.discoveredWorkspaces;
212+
}
213+
}
214+
);
173215

174216
this._client = await createClient(
175217
this.traceOutputChannel,
@@ -251,6 +293,17 @@ export class Ctx {
251293
return this._serverPath;
252294
}
253295

296+
addToDiscoveredWorkspaces(workspaces: JsonProject[]) {
297+
for (const workspace of workspaces) {
298+
const index = this.config.discoveredWorkspaces.indexOf(workspace);
299+
if (~index) {
300+
this.config.discoveredWorkspaces[index] = workspace;
301+
} else {
302+
this.config.discoveredWorkspaces.push(workspace);
303+
}
304+
}
305+
}
306+
254307
private updateCommands(forceDisable?: "disable") {
255308
this.commandDisposables.forEach((disposable) => disposable.dispose());
256309
this.commandDisposables = [];

editors/code/src/lsp_ext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const relatedTests = new lc.RequestType<lc.TextDocumentPositionParams, Te
4343
"rust-analyzer/relatedTests"
4444
);
4545
export const reloadWorkspace = new lc.RequestType0<null, void>("rust-analyzer/reloadWorkspace");
46+
4647
export const runFlycheck = new lc.NotificationType<{
4748
textDocument: lc.TextDocumentIdentifier | null;
4849
}>("rust-analyzer/runFlycheck");

editors/code/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ function createCommands(): Record<string, CommandFactory> {
153153
memoryUsage: { enabled: commands.memoryUsage },
154154
shuffleCrateGraph: { enabled: commands.shuffleCrateGraph },
155155
reloadWorkspace: { enabled: commands.reloadWorkspace },
156+
addProject: { enabled: commands.addProject },
156157
matchingBrace: { enabled: commands.matchingBrace },
157158
joinLines: { enabled: commands.joinLines },
158159
parentModule: { enabled: commands.parentModule },

0 commit comments

Comments
 (0)