This commit 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 "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.

I think that the command-running functionality _could_ merit being
placed into its own extension (and expose it via extension contribution
points), if only provide build-system idiomatic progress reporting and
status handling, but I haven't (yet) made an extension that does this.
This commit is contained in:
David Barsky 2023-03-09 15:06:26 -05:00
parent 9549753352
commit 8af3d6367e
14 changed files with 258 additions and 25 deletions

View file

@ -4,6 +4,7 @@
use std::{fmt, str::FromStr}; use std::{fmt, str::FromStr};
use cfg::CfgOptions; use cfg::CfgOptions;
use serde::Serialize;
#[derive(Clone, Eq, PartialEq, Debug)] #[derive(Clone, Eq, PartialEq, Debug)]
pub enum CfgFlag { pub enum CfgFlag {
@ -38,6 +39,18 @@ impl<'de> serde::Deserialize<'de> for CfgFlag {
} }
} }
impl Serialize for CfgFlag {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
CfgFlag::Atom(s) => serializer.serialize_str(s),
CfgFlag::KeyValue { .. } => serializer.serialize_str(&format!("{}", &self)),
}
}
}
impl Extend<CfgFlag> for CfgOptions { impl Extend<CfgFlag> for CfgOptions {
fn extend<T: IntoIterator<Item = CfgFlag>>(&mut self, iter: T) { fn extend<T: IntoIterator<Item = CfgFlag>>(&mut self, iter: T) {
for cfg_flag in iter { for cfg_flag in iter {

View file

@ -54,7 +54,7 @@ use std::path::PathBuf;
use base_db::{CrateDisplayName, CrateId, CrateName, Dependency, Edition}; use base_db::{CrateDisplayName, CrateId, CrateName, Dependency, Edition};
use paths::{AbsPath, AbsPathBuf}; use paths::{AbsPath, AbsPathBuf};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::{de, Deserialize}; use serde::{de, ser, Deserialize, Serialize};
use crate::cfg_flag::CfgFlag; use crate::cfg_flag::CfgFlag;
@ -171,14 +171,14 @@ impl ProjectJson {
} }
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ProjectJsonData { pub struct ProjectJsonData {
sysroot: Option<PathBuf>, sysroot: Option<PathBuf>,
sysroot_src: Option<PathBuf>, sysroot_src: Option<PathBuf>,
crates: Vec<CrateData>, crates: Vec<CrateData>,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
struct CrateData { struct CrateData {
display_name: Option<String>, display_name: Option<String>,
root_module: PathBuf, root_module: PathBuf,
@ -200,7 +200,7 @@ struct CrateData {
repository: Option<String>, repository: Option<String>,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename = "edition")] #[serde(rename = "edition")]
enum EditionData { enum EditionData {
#[serde(rename = "2015")] #[serde(rename = "2015")]
@ -221,16 +221,16 @@ impl From<EditionData> for Edition {
} }
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
struct DepData { struct DepData {
/// Identifies a crate by position in the crates array. /// Identifies a crate by position in the crates array.
#[serde(rename = "crate")] #[serde(rename = "crate")]
krate: usize, krate: usize,
#[serde(deserialize_with = "deserialize_crate_name")] #[serde(deserialize_with = "deserialize_crate_name", serialize_with = "serialize_crate_name")]
name: CrateName, name: CrateName,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
struct CrateSource { struct CrateSource {
include_dirs: Vec<PathBuf>, include_dirs: Vec<PathBuf>,
exclude_dirs: Vec<PathBuf>, exclude_dirs: Vec<PathBuf>,
@ -243,3 +243,10 @@ where
let name = String::deserialize(de)?; let name = String::deserialize(de)?;
CrateName::new(&name).map_err(|err| de::Error::custom(format!("invalid crate name: {err:?}"))) CrateName::new(&name).map_err(|err| de::Error::custom(format!("invalid crate name: {err:?}")))
} }
fn serialize_crate_name<S>(crate_name: &CrateName, serializer: S) -> Result<S::Ok, S::Error>
where
S: ser::Serializer,
{
crate_name.serialize(serializer)
}

View file

@ -272,7 +272,6 @@ config_data! {
/// The warnings will be indicated by a blue squiggly underline in code /// The warnings will be indicated by a blue squiggly underline in code
/// and a blue icon in the `Problems Panel`. /// and a blue icon in the `Problems Panel`.
diagnostics_warningsAsInfo: Vec<String> = "[]", diagnostics_warningsAsInfo: Vec<String> = "[]",
/// These directories will be ignored by rust-analyzer. They are /// These directories will be ignored by rust-analyzer. They are
/// relative to the workspace root, and globs are not supported. You may /// relative to the workspace root, and globs are not supported. You may
/// also need to add the folders to Code's `files.watcherExclude`. /// also need to add the folders to Code's `files.watcherExclude`.
@ -895,6 +894,15 @@ impl Config {
} }
} }
pub fn add_linked_projects(&mut self, linked_projects: Vec<ProjectJsonData>) {
let mut linked_projects = linked_projects
.into_iter()
.map(ManifestOrProjectJson::ProjectJson)
.collect::<Vec<ManifestOrProjectJson>>();
self.data.linkedProjects.append(&mut linked_projects);
}
pub fn did_save_text_document_dynamic_registration(&self) -> bool { pub fn did_save_text_document_dynamic_registration(&self) -> bool {
let caps = try_or_def!(self.caps.text_document.as_ref()?.synchronization.clone()?); let caps = try_or_def!(self.caps.text_document.as_ref()?.synchronization.clone()?);
caps.did_save == Some(true) && caps.dynamic_registration == Some(true) caps.did_save == Some(true) && caps.dynamic_registration == Some(true)

View file

@ -5,6 +5,7 @@
use std::{ use std::{
io::Write as _, io::Write as _,
process::{self, Stdio}, process::{self, Stdio},
sync::Arc,
}; };
use anyhow::Context; use anyhow::Context;
@ -46,6 +47,22 @@ use crate::{
pub(crate) fn handle_workspace_reload(state: &mut GlobalState, _: ()) -> Result<()> { pub(crate) fn handle_workspace_reload(state: &mut GlobalState, _: ()) -> Result<()> {
state.proc_macro_clients.clear(); state.proc_macro_clients.clear();
state.proc_macro_changed = false; state.proc_macro_changed = false;
state.fetch_workspaces_queue.request_op("reload workspace request".to_string());
state.fetch_build_data_queue.request_op("reload workspace request".to_string());
Ok(())
}
pub(crate) fn handle_add_project(
state: &mut GlobalState,
params: lsp_ext::AddProjectParams,
) -> Result<()> {
state.proc_macro_clients.clear();
state.proc_macro_changed = false;
let config = Arc::make_mut(&mut state.config);
config.add_linked_projects(params.project);
state.fetch_workspaces_queue.request_op("reload workspace request".to_string()); state.fetch_workspaces_queue.request_op("reload workspace request".to_string());
state.fetch_build_data_queue.request_op("reload workspace request".to_string()); state.fetch_build_data_queue.request_op("reload workspace request".to_string());
Ok(()) Ok(())

View file

@ -9,6 +9,7 @@ use lsp_types::{
notification::Notification, CodeActionKind, DocumentOnTypeFormattingParams, notification::Notification, CodeActionKind, DocumentOnTypeFormattingParams,
PartialResultParams, Position, Range, TextDocumentIdentifier, WorkDoneProgressParams, PartialResultParams, Position, Range, TextDocumentIdentifier, WorkDoneProgressParams,
}; };
use project_model::ProjectJsonData;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::line_index::PositionEncoding; use crate::line_index::PositionEncoding;
@ -51,6 +52,20 @@ impl Request for ReloadWorkspace {
const METHOD: &'static str = "rust-analyzer/reloadWorkspace"; const METHOD: &'static str = "rust-analyzer/reloadWorkspace";
} }
pub enum AddProject {}
impl Request for AddProject {
type Params = AddProjectParams;
type Result = ();
const METHOD: &'static str = "rust-analyzer/addProject";
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AddProjectParams {
pub project: Vec<ProjectJsonData>,
}
pub enum SyntaxTree {} pub enum SyntaxTree {}
impl Request for SyntaxTree { impl Request for SyntaxTree {

View file

@ -625,6 +625,7 @@ impl GlobalState {
.on_sync_mut::<lsp_ext::ReloadWorkspace>(handlers::handle_workspace_reload) .on_sync_mut::<lsp_ext::ReloadWorkspace>(handlers::handle_workspace_reload)
.on_sync_mut::<lsp_ext::MemoryUsage>(handlers::handle_memory_usage) .on_sync_mut::<lsp_ext::MemoryUsage>(handlers::handle_memory_usage)
.on_sync_mut::<lsp_ext::ShuffleCrateGraph>(handlers::handle_shuffle_crate_graph) .on_sync_mut::<lsp_ext::ShuffleCrateGraph>(handlers::handle_shuffle_crate_graph)
.on_sync_mut::<lsp_ext::AddProject>(handlers::handle_add_project)
.on_sync::<lsp_ext::JoinLines>(handlers::handle_join_lines) .on_sync::<lsp_ext::JoinLines>(handlers::handle_join_lines)
.on_sync::<lsp_ext::OnEnter>(handlers::handle_on_enter) .on_sync::<lsp_ext::OnEnter>(handlers::handle_on_enter)
.on_sync::<lsp_types::request::SelectionRangeRequest>(handlers::handle_selection_range) .on_sync::<lsp_types::request::SelectionRangeRequest>(handlers::handle_selection_range)

View file

@ -199,6 +199,11 @@
"title": "Reload workspace", "title": "Reload workspace",
"category": "rust-analyzer" "category": "rust-analyzer"
}, },
{
"command": "rust-analyzer.addProject",
"title": "Add current file to workspace",
"category": "rust-analyzer"
},
{ {
"command": "rust-analyzer.reload", "command": "rust-analyzer.reload",
"title": "Restart server", "title": "Restart server",
@ -447,6 +452,17 @@
"Fill missing expressions with reasonable defaults, `new` or `default` constructors." "Fill missing expressions with reasonable defaults, `new` or `default` constructors."
] ]
}, },
"rust-analyzer.discoverProjectCommand": {
"markdownDescription": "Sets the command that rust-analyzer uses to generate `rust-project.json` files. This command is\n only suggested if a build system like Buck or Bazel is used. The command must accept files as arguements and return \n a rust-project.json over stdout.",
"default": null,
"type": [
"null",
"array"
],
"items": {
"type": "string"
}
},
"rust-analyzer.cachePriming.enable": { "rust-analyzer.cachePriming.enable": {
"markdownDescription": "Warm up caches on project load.", "markdownDescription": "Warm up caches on project load.",
"default": true, "default": true,

View file

@ -3,7 +3,7 @@ import * as lc from "vscode-languageclient";
import * as ra from "./lsp_ext"; import * as ra from "./lsp_ext";
import * as path from "path"; import * as path from "path";
import { Ctx, Cmd, CtxInit } from "./ctx"; import { Ctx, Cmd, CtxInit, discoverWorkspace } from "./ctx";
import { applySnippetWorkspaceEdit, applySnippetTextEdits } from "./snippets"; import { applySnippetWorkspaceEdit, applySnippetTextEdits } from "./snippets";
import { spawnSync } from "child_process"; import { spawnSync } from "child_process";
import { RunnableQuickPick, selectRunnable, createTask, createArgs } from "./run"; import { RunnableQuickPick, selectRunnable, createTask, createArgs } from "./run";
@ -749,6 +749,23 @@ export function reloadWorkspace(ctx: CtxInit): Cmd {
return async () => ctx.client.sendRequest(ra.reloadWorkspace); return async () => ctx.client.sendRequest(ra.reloadWorkspace);
} }
export function addProject(ctx: CtxInit): Cmd {
return async () => {
const discoverProjectCommand = ctx.config.discoverProjectCommand;
if (!discoverProjectCommand) {
return;
}
let workspaces: JsonProject[] = await Promise.all(vscode.workspace.workspaceFolders!.map(async (folder): Promise<JsonProject> => {
return discoverWorkspace(vscode.workspace.textDocuments, discoverProjectCommand, { cwd: folder.uri.fsPath });
}));
await ctx.client.sendRequest(ra.addProject, {
project: workspaces
});
}
}
async function showReferencesImpl( async function showReferencesImpl(
client: LanguageClient | undefined, client: LanguageClient | undefined,
uri: string, uri: string,

View file

@ -214,6 +214,10 @@ export class Config {
return this.get<boolean>("trace.extension"); return this.get<boolean>("trace.extension");
} }
get discoverProjectCommand() {
return this.get<string[] | undefined>("discoverProjectCommand")
}
get cargoRunner() { get cargoRunner() {
return this.get<string | undefined>("cargoRunner"); return this.get<string | undefined>("cargoRunner");
} }

View file

@ -4,10 +4,11 @@ import * as ra from "./lsp_ext";
import { Config, substituteVSCodeVariables } from "./config"; import { Config, substituteVSCodeVariables } from "./config";
import { createClient } from "./client"; import { createClient } from "./client";
import { isRustDocument, isRustEditor, LazyOutputChannel, log, RustEditor } from "./util"; import { executeDiscoverProject, isRustDocument, isRustEditor, LazyOutputChannel, log, RustEditor } from "./util";
import { ServerStatusParams } from "./lsp_ext"; import { ServerStatusParams } from "./lsp_ext";
import { PersistentState } from "./persistent_state"; import { PersistentState } from "./persistent_state";
import { bootstrap } from "./bootstrap"; import { bootstrap } from "./bootstrap";
import { ExecOptions } from "child_process";
// We only support local folders, not eg. Live Share (`vlsl:` scheme), so don't activate if // We only support local folders, not eg. Live Share (`vlsl:` scheme), so don't activate if
// only those are in use. We use "Empty" to represent these scenarios // only those are in use. We use "Empty" to represent these scenarios
@ -41,6 +42,13 @@ export function fetchWorkspace(): Workspace {
: { kind: "Workspace Folder" }; : { kind: "Workspace Folder" };
} }
export async function discoverWorkspace(files: readonly vscode.TextDocument[], command: string[], options: ExecOptions): Promise<JsonProject> {
const paths = files.map((f) => f.uri.fsPath).join(" ");
const joinedCommand = command.join(" ");
const data = await executeDiscoverProject(`${joinedCommand} -- ${paths}`, options);
return JSON.parse(data) as JsonProject;
}
export type CommandFactory = { export type CommandFactory = {
enabled: (ctx: CtxInit) => Cmd; enabled: (ctx: CtxInit) => Cmd;
disabled?: (ctx: Ctx) => Cmd; disabled?: (ctx: Ctx) => Cmd;
@ -63,6 +71,7 @@ export class Ctx {
private state: PersistentState; private state: PersistentState;
private commandFactories: Record<string, CommandFactory>; private commandFactories: Record<string, CommandFactory>;
private commandDisposables: Disposable[]; private commandDisposables: Disposable[];
private discoveredWorkspaces: JsonProject[] | undefined;
get client() { get client() {
return this._client; return this._client;
@ -71,7 +80,7 @@ export class Ctx {
constructor( constructor(
readonly extCtx: vscode.ExtensionContext, readonly extCtx: vscode.ExtensionContext,
commandFactories: Record<string, CommandFactory>, commandFactories: Record<string, CommandFactory>,
workspace: Workspace workspace: Workspace,
) { ) {
extCtx.subscriptions.push(this); extCtx.subscriptions.push(this);
this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
@ -169,7 +178,18 @@ export class Ctx {
}; };
} }
const initializationOptions = substituteVSCodeVariables(rawInitializationOptions); const discoverProjectCommand = this.config.discoverProjectCommand;
if (discoverProjectCommand) {
let workspaces: JsonProject[] = await Promise.all(vscode.workspace.workspaceFolders!.map(async (folder): Promise<JsonProject> => {
return discoverWorkspace(vscode.workspace.textDocuments, discoverProjectCommand, { cwd: folder.uri.fsPath });
}));
this.discoveredWorkspaces = workspaces;
}
let initializationOptions = substituteVSCodeVariables(rawInitializationOptions);
// this appears to be load-bearing, for better or worse.
await initializationOptions.update('linkedProjects', this.discoveredWorkspaces)
this._client = await createClient( this._client = await createClient(
this.traceOutputChannel, this.traceOutputChannel,

View file

@ -43,6 +43,10 @@ export const relatedTests = new lc.RequestType<lc.TextDocumentPositionParams, Te
"rust-analyzer/relatedTests" "rust-analyzer/relatedTests"
); );
export const reloadWorkspace = new lc.RequestType0<null, void>("rust-analyzer/reloadWorkspace"); export const reloadWorkspace = new lc.RequestType0<null, void>("rust-analyzer/reloadWorkspace");
export const addProject = new lc.RequestType<AddProjectParams, string, void>(
"rust-analyzer/addProject"
)
export const runFlycheck = new lc.NotificationType<{ export const runFlycheck = new lc.NotificationType<{
textDocument: lc.TextDocumentIdentifier | null; textDocument: lc.TextDocumentIdentifier | null;
}>("rust-analyzer/runFlycheck"); }>("rust-analyzer/runFlycheck");
@ -68,6 +72,8 @@ export const viewItemTree = new lc.RequestType<ViewItemTreeParams, string, void>
export type AnalyzerStatusParams = { textDocument?: lc.TextDocumentIdentifier }; export type AnalyzerStatusParams = { textDocument?: lc.TextDocumentIdentifier };
export type AddProjectParams = { project: JsonProject[] };
export type ExpandMacroParams = { export type ExpandMacroParams = {
textDocument: lc.TextDocumentIdentifier; textDocument: lc.TextDocumentIdentifier;
position: lc.Position; position: lc.Position;

View file

@ -28,7 +28,7 @@ export async function activate(
"both plugins to not work correctly. You should disable one of them.", "both plugins to not work correctly. You should disable one of them.",
"Got it" "Got it"
) )
.then(() => {}, console.error); .then(() => { }, console.error);
} }
const ctx = new Ctx(context, createCommands(), fetchWorkspace()); const ctx = new Ctx(context, createCommands(), fetchWorkspace());
@ -146,13 +146,14 @@ function createCommands(): Record<string, CommandFactory> {
health: "stopped", health: "stopped",
}); });
}, },
disabled: (_) => async () => {}, disabled: (_) => async () => { },
}, },
analyzerStatus: { enabled: commands.analyzerStatus }, analyzerStatus: { enabled: commands.analyzerStatus },
memoryUsage: { enabled: commands.memoryUsage }, memoryUsage: { enabled: commands.memoryUsage },
shuffleCrateGraph: { enabled: commands.shuffleCrateGraph }, shuffleCrateGraph: { enabled: commands.shuffleCrateGraph },
reloadWorkspace: { enabled: commands.reloadWorkspace }, reloadWorkspace: { enabled: commands.reloadWorkspace },
addProject: { enabled: commands.addProject },
matchingBrace: { enabled: commands.matchingBrace }, matchingBrace: { enabled: commands.matchingBrace },
joinLines: { enabled: commands.joinLines }, joinLines: { enabled: commands.joinLines },
parentModule: { enabled: commands.parentModule }, parentModule: { enabled: commands.parentModule },

View file

@ -0,0 +1,91 @@
interface JsonProject {
/// Path to the directory with *source code* of
/// sysroot crates.
///
/// It should point to the directory where std,
/// core, and friends can be found:
///
/// https://github.com/rust-lang/rust/tree/master/library.
///
/// If provided, rust-analyzer automatically adds
/// dependencies on sysroot crates. Conversely,
/// if you omit this path, you can specify sysroot
/// dependencies yourself and, for example, have
/// several different "sysroots" in one graph of
/// crates.
sysroot_src?: string;
/// The set of crates comprising the current
/// project. Must include all transitive
/// dependencies as well as sysroot crate (libstd,
/// libcore and such).
crates: Crate[];
}
interface Crate {
/// Optional crate name used for display purposes,
/// without affecting semantics. See the `deps`
/// key for semantically-significant crate names.
display_name?: string;
/// Path to the root module of the crate.
root_module: string;
/// Edition of the crate.
edition: "2015" | "2018" | "2021";
/// Dependencies
deps: Dep[];
/// Should this crate be treated as a member of
/// current "workspace".
///
/// By default, inferred from the `root_module`
/// (members are the crates which reside inside
/// the directory opened in the editor).
///
/// Set this to `false` for things like standard
/// library and 3rd party crates to enable
/// performance optimizations (rust-analyzer
/// assumes that non-member crates don't change).
is_workspace_member?: boolean;
/// Optionally specify the (super)set of `.rs`
/// files comprising this crate.
///
/// By default, rust-analyzer assumes that only
/// files under `root_module.parent` can belong
/// to a crate. `include_dirs` are included
/// recursively, unless a subdirectory is in
/// `exclude_dirs`.
///
/// Different crates can share the same `source`.
///
/// If two crates share an `.rs` file in common,
/// they *must* have the same `source`.
/// rust-analyzer assumes that files from one
/// source can't refer to files in another source.
source?: {
include_dirs: string[],
exclude_dirs: string[],
},
/// The set of cfgs activated for a given crate, like
/// `["unix", "feature=\"foo\"", "feature=\"bar\""]`.
cfg: string[];
/// Target triple for this Crate.
///
/// Used when running `rustc --print cfg`
/// to get target-specific cfgs.
target?: string;
/// Environment variables, used for
/// the `env!` macro
env: { [key: string]: string; },
/// Whether the crate is a proc-macro crate.
is_proc_macro: boolean;
/// For proc-macro crates, path to compiled
/// proc-macro (.so file).
proc_macro_dylib_path?: string;
}
interface Dep {
/// Index of a crate in the `crates` array.
crate: number,
/// Name as should appear in the (implicit)
/// `extern crate name` declaration.
name: string,
}

View file

@ -150,9 +150,11 @@ export function memoizeAsync<Ret, TThis, Param extends string>(
/** Awaitable wrapper around `child_process.exec` */ /** Awaitable wrapper around `child_process.exec` */
export function execute(command: string, options: ExecOptions): Promise<string> { export function execute(command: string, options: ExecOptions): Promise<string> {
log.info(`running command: ${command}`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(command, options, (err, stdout, stderr) => { exec(command, options, (err, stdout, stderr) => {
if (err) { if (err) {
log.error(err);
reject(err); reject(err);
return; return;
} }
@ -167,6 +169,21 @@ export function execute(command: string, options: ExecOptions): Promise<string>
}); });
} }
export function executeDiscoverProject(command: string, options: ExecOptions): Promise<string> {
log.info(`running command: ${command}`)
return new Promise((resolve, reject) => {
exec(command, options, (err, stdout, _) => {
if (err) {
log.error(err);
reject(err);
return;
}
resolve(stdout.trimEnd());
});
});
}
export class LazyOutputChannel implements vscode.OutputChannel { export class LazyOutputChannel implements vscode.OutputChannel {
constructor(name: string) { constructor(name: string) {
this.name = name; this.name = name;