Implement invocation strategy config for build scripts

This commit is contained in:
Lukas Wirth 2022-08-27 18:28:09 +02:00
parent 40cbeb5b3d
commit 7e2c41dbd6
8 changed files with 373 additions and 162 deletions

View file

@ -6,7 +6,12 @@
//! This module implements this second part. We use "build script" terminology
//! here, but it covers procedural macros as well.
use std::{cell::RefCell, io, path::PathBuf, process::Command};
use std::{
cell::RefCell,
io, mem,
path::{self, PathBuf},
process::Command,
};
use cargo_metadata::{camino::Utf8Path, Message};
use la_arena::ArenaMap;
@ -15,11 +20,13 @@ use rustc_hash::FxHashMap;
use semver::Version;
use serde::Deserialize;
use crate::{cfg_flag::CfgFlag, CargoConfig, CargoFeatures, CargoWorkspace, Package};
use crate::{
cfg_flag::CfgFlag, CargoConfig, CargoFeatures, CargoWorkspace, InvocationStrategy, Package,
};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct WorkspaceBuildScripts {
outputs: ArenaMap<Package, Option<BuildScriptOutput>>,
outputs: ArenaMap<Package, BuildScriptOutput>,
error: Option<String>,
}
@ -38,17 +45,26 @@ pub(crate) struct BuildScriptOutput {
pub(crate) proc_macro_dylib_path: Option<AbsPathBuf>,
}
impl WorkspaceBuildScripts {
fn build_command(config: &CargoConfig) -> Command {
if let Some([program, args @ ..]) = config.run_build_script_command.as_deref() {
let mut cmd = Command::new(program);
cmd.args(args);
cmd.envs(&config.extra_env);
return cmd;
impl BuildScriptOutput {
fn is_unchanged(&self) -> bool {
self.cfgs.is_empty()
&& self.envs.is_empty()
&& self.out_dir.is_none()
&& self.proc_macro_dylib_path.is_none()
}
}
impl WorkspaceBuildScripts {
fn build_command(config: &CargoConfig) -> io::Result<Command> {
let mut cmd = match config.run_build_script_command.as_deref() {
Some([program, args @ ..]) => {
let mut cmd = Command::new(program);
cmd.args(args);
cmd
}
_ => {
let mut cmd = Command::new(toolchain::cargo());
cmd.envs(&config.extra_env);
cmd.args(&["check", "--quiet", "--workspace", "--message-format=json"]);
// --all-targets includes tests, benches and examples in addition to the
@ -74,40 +90,10 @@ impl WorkspaceBuildScripts {
}
}
}
cmd
}
pub(crate) fn run(
config: &CargoConfig,
workspace: &CargoWorkspace,
progress: &dyn Fn(String),
toolchain: &Option<Version>,
) -> io::Result<WorkspaceBuildScripts> {
const RUST_1_62: Version = Version::new(1, 62, 0);
match Self::run_(Self::build_command(config), config, workspace, progress) {
Ok(WorkspaceBuildScripts { error: Some(error), .. })
if toolchain.as_ref().map_or(false, |it| *it >= RUST_1_62) =>
{
// building build scripts failed, attempt to build with --keep-going so
// that we potentially get more build data
let mut cmd = Self::build_command(config);
cmd.args(&["-Z", "unstable-options", "--keep-going"]).env("RUSTC_BOOTSTRAP", "1");
let mut res = Self::run_(cmd, config, workspace, progress)?;
res.error = Some(error);
Ok(res)
}
res => res,
}
}
fn run_(
mut cmd: Command,
config: &CargoConfig,
workspace: &CargoWorkspace,
progress: &dyn Fn(String),
) -> io::Result<WorkspaceBuildScripts> {
};
cmd.envs(&config.extra_env);
if config.wrap_rustc_in_build_scripts {
// Setup RUSTC_WRAPPER to point to `rust-analyzer` binary itself. We use
// that to compile only proc macros and build scripts during the initial
@ -117,7 +103,107 @@ impl WorkspaceBuildScripts {
cmd.env("RA_RUSTC_WRAPPER", "1");
}
cmd.current_dir(workspace.workspace_root());
Ok(cmd)
}
/// Runs the build scripts for the given workspace
pub(crate) fn run_for_workspace(
config: &CargoConfig,
workspace: &CargoWorkspace,
progress: &dyn Fn(String),
toolchain: &Option<Version>,
) -> io::Result<WorkspaceBuildScripts> {
const RUST_1_62: Version = Version::new(1, 62, 0);
match Self::run_per_ws(Self::build_command(config)?, config, workspace, progress) {
Ok(WorkspaceBuildScripts { error: Some(error), .. })
if toolchain.as_ref().map_or(false, |it| *it >= RUST_1_62) =>
{
// building build scripts failed, attempt to build with --keep-going so
// that we potentially get more build data
let mut cmd = Self::build_command(config)?;
cmd.args(&["-Z", "unstable-options", "--keep-going"]).env("RUSTC_BOOTSTRAP", "1");
let mut res = Self::run_per_ws(cmd, config, workspace, progress)?;
res.error = Some(error);
Ok(res)
}
res => res,
}
}
/// Runs the build scripts by invoking the configured command *once*.
/// This populates the outputs for all passed in workspaces.
pub(crate) fn run_once(
config: &CargoConfig,
workspaces: &[&CargoWorkspace],
progress: &dyn Fn(String),
) -> io::Result<Vec<WorkspaceBuildScripts>> {
assert_eq!(config.invocation_strategy, InvocationStrategy::OnceInRoot);
let cmd = Self::build_command(config)?;
// NB: Cargo.toml could have been modified between `cargo metadata` and
// `cargo check`. We shouldn't assume that package ids we see here are
// exactly those from `config`.
let mut by_id = FxHashMap::default();
let mut res: Vec<_> = workspaces
.iter()
.enumerate()
.map(|(idx, workspace)| {
let mut res = WorkspaceBuildScripts::default();
for package in workspace.packages() {
res.outputs.insert(package, BuildScriptOutput::default());
by_id.insert(workspace[package].id.clone(), (package, idx));
}
res
})
.collect();
let errors = Self::run_command(
cmd,
|package, cb| {
if let Some(&(package, workspace)) = by_id.get(package) {
cb(&workspaces[workspace][package].name, &mut res[workspace].outputs[package]);
}
},
progress,
)?;
res.iter_mut().for_each(|it| it.error = errors.clone());
if tracing::enabled!(tracing::Level::INFO) {
for (idx, workspace) in workspaces.iter().enumerate() {
for package in workspace.packages() {
let package_build_data = &mut res[idx].outputs[package];
if !package_build_data.is_unchanged() {
tracing::info!(
"{}: {:?}",
workspace[package].manifest.parent().display(),
package_build_data,
);
}
}
}
}
Ok(res)
}
fn run_per_ws(
mut cmd: Command,
config: &CargoConfig,
workspace: &CargoWorkspace,
progress: &dyn Fn(String),
) -> io::Result<WorkspaceBuildScripts> {
let workspace_root: &path::Path = &workspace.workspace_root().as_ref();
match config.invocation_strategy {
InvocationStrategy::OnceInRoot => (),
InvocationStrategy::PerWorkspaceWithManifestPath => {
cmd.arg("--manifest-path");
cmd.arg(workspace_root.join("Cargo.toml"));
}
InvocationStrategy::PerWorkspace => {
cmd.current_dir(workspace_root);
}
}
let mut res = WorkspaceBuildScripts::default();
let outputs = &mut res.outputs;
@ -126,10 +212,44 @@ impl WorkspaceBuildScripts {
// exactly those from `config`.
let mut by_id: FxHashMap<String, Package> = FxHashMap::default();
for package in workspace.packages() {
outputs.insert(package, None);
outputs.insert(package, BuildScriptOutput::default());
by_id.insert(workspace[package].id.clone(), package);
}
res.error = Self::run_command(
cmd,
|package, cb| {
if let Some(&package) = by_id.get(package) {
cb(&workspace[package].name, &mut outputs[package]);
}
},
progress,
)?;
if tracing::enabled!(tracing::Level::INFO) {
for package in workspace.packages() {
let package_build_data = &mut outputs[package];
if !package_build_data.is_unchanged() {
tracing::info!(
"{}: {:?}",
workspace[package].manifest.parent().display(),
package_build_data,
);
}
}
}
Ok(res)
}
fn run_command(
cmd: Command,
// ideally this would be something like:
// with_output_for: impl FnMut(&str, dyn FnOnce(&mut BuildScriptOutput)),
// but owned trait objects aren't a thing
mut with_output_for: impl FnMut(&str, &mut dyn FnMut(&str, &mut BuildScriptOutput)),
progress: &dyn Fn(String),
) -> io::Result<Option<String>> {
let errors = RefCell::new(String::new());
let push_err = |err: &str| {
let mut e = errors.borrow_mut();
@ -149,16 +269,12 @@ impl WorkspaceBuildScripts {
.unwrap_or_else(|_| Message::TextLine(line.to_string()));
match message {
Message::BuildScriptExecuted(message) => {
let package = match by_id.get(&message.package_id.repr) {
Some(&it) => it,
None => return,
};
progress(format!("running build-script: {}", workspace[package].name));
Message::BuildScriptExecuted(mut message) => {
with_output_for(&message.package_id.repr, &mut |name, data| {
progress(format!("running build-script: {}", name));
let cfgs = {
let mut acc = Vec::new();
for cfg in message.cfgs {
for cfg in &message.cfgs {
match cfg.parse::<CfgFlag>() {
Ok(it) => acc.push(it),
Err(err) => {
@ -172,38 +288,39 @@ impl WorkspaceBuildScripts {
}
acc
};
if !message.env.is_empty() {
data.envs = mem::take(&mut message.env);
}
// cargo_metadata crate returns default (empty) path for
// older cargos, which is not absolute, so work around that.
let out_dir = message.out_dir.into_os_string();
let out_dir = mem::take(&mut message.out_dir).into_os_string();
if !out_dir.is_empty() {
let data = outputs[package].get_or_insert_with(Default::default);
data.out_dir = Some(AbsPathBuf::assert(PathBuf::from(out_dir)));
let out_dir = AbsPathBuf::assert(PathBuf::from(out_dir));
// inject_cargo_env(package, package_build_data);
// NOTE: cargo and rustc seem to hide non-UTF-8 strings from env! and option_env!()
if let Some(out_dir) =
out_dir.as_os_str().to_str().map(|s| s.to_owned())
{
data.envs.push(("OUT_DIR".to_string(), out_dir));
}
data.out_dir = Some(out_dir);
data.cfgs = cfgs;
}
if !message.env.is_empty() {
outputs[package].get_or_insert_with(Default::default).envs =
message.env;
}
});
}
Message::CompilerArtifact(message) => {
let package = match by_id.get(&message.package_id.repr) {
Some(it) => *it,
None => return,
};
progress(format!("building proc-macros: {}", message.target.name));
with_output_for(&message.package_id.repr, &mut |name, data| {
progress(format!("building proc-macros: {}", name));
if message.target.kind.iter().any(|k| k == "proc-macro") {
// Skip rmeta file
if let Some(filename) =
message.filenames.iter().find(|name| is_dylib(name))
{
let filename = AbsPathBuf::assert(PathBuf::from(&filename));
outputs[package]
.get_or_insert_with(Default::default)
.proc_macro_dylib_path = Some(filename);
data.proc_macro_dylib_path = Some(filename);
}
}
});
}
Message::CompilerMessage(message) => {
progress(message.target.name);
@ -222,32 +339,13 @@ impl WorkspaceBuildScripts {
},
)?;
for package in workspace.packages() {
if let Some(package_build_data) = &mut outputs[package] {
tracing::info!(
"{}: {:?}",
workspace[package].manifest.parent().display(),
package_build_data,
);
// inject_cargo_env(package, package_build_data);
if let Some(out_dir) = &package_build_data.out_dir {
// NOTE: cargo and rustc seem to hide non-UTF-8 strings from env! and option_env!()
if let Some(out_dir) = out_dir.as_os_str().to_str().map(|s| s.to_owned()) {
package_build_data.envs.push(("OUT_DIR".to_string(), out_dir));
}
}
}
}
let mut errors = errors.into_inner();
if !output.status.success() {
if errors.is_empty() {
errors = "cargo check failed".to_string();
}
res.error = Some(errors);
}
Ok(res)
let errors = if !output.status.success() {
let errors = errors.into_inner();
Some(if errors.is_empty() { "cargo check failed".to_string() } else { errors })
} else {
None
};
Ok(errors)
}
pub fn error(&self) -> Option<&str> {
@ -255,11 +353,11 @@ impl WorkspaceBuildScripts {
}
pub(crate) fn get_output(&self, idx: Package) -> Option<&BuildScriptOutput> {
self.outputs.get(idx)?.as_ref()
self.outputs.get(idx)
}
}
// FIXME: File a better way to know if it is a dylib.
// FIXME: Find a better way to know if it is a dylib.
fn is_dylib(path: &Utf8Path) -> bool {
match path.extension().map(|e| e.to_string().to_lowercase()) {
None => false,

View file

@ -14,8 +14,8 @@ use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde_json::from_value;
use crate::CfgOverrides;
use crate::{utf8_stdout, ManifestPath};
use crate::{CfgOverrides, InvocationStrategy};
/// [`CargoWorkspace`] represents the logical structure of, well, a Cargo
/// workspace. It pretty closely mirrors `cargo metadata` output.
@ -106,6 +106,7 @@ pub struct CargoConfig {
pub run_build_script_command: Option<Vec<String>>,
/// Extra env vars to set when invoking the cargo command
pub extra_env: FxHashMap<String, String>,
pub invocation_strategy: InvocationStrategy,
}
impl CargoConfig {

View file

@ -157,3 +157,11 @@ fn utf8_stdout(mut cmd: Command) -> Result<String> {
let stdout = String::from_utf8(output.stdout)?;
Ok(stdout.trim().to_string())
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum InvocationStrategy {
OnceInRoot,
PerWorkspaceWithManifestPath,
#[default]
PerWorkspace,
}

View file

@ -2,7 +2,7 @@
//! metadata` or `rust-project.json`) into representation stored in the salsa
//! database -- `CrateGraph`.
use std::{collections::VecDeque, fmt, fs, process::Command};
use std::{collections::VecDeque, fmt, fs, process::Command, sync::Arc};
use anyhow::{format_err, Context, Result};
use base_db::{
@ -21,8 +21,8 @@ use crate::{
cfg_flag::CfgFlag,
rustc_cfg,
sysroot::SysrootCrate,
utf8_stdout, CargoConfig, CargoWorkspace, ManifestPath, Package, ProjectJson, ProjectManifest,
Sysroot, TargetKind, WorkspaceBuildScripts,
utf8_stdout, CargoConfig, CargoWorkspace, InvocationStrategy, ManifestPath, Package,
ProjectJson, ProjectManifest, Sysroot, TargetKind, WorkspaceBuildScripts,
};
/// A set of cfg-overrides per crate.
@ -294,6 +294,7 @@ impl ProjectWorkspace {
Ok(ProjectWorkspace::DetachedFiles { files: detached_files, sysroot, rustc_cfg })
}
/// Runs the build scripts for this [`ProjectWorkspace`].
pub fn run_build_scripts(
&self,
config: &CargoConfig,
@ -301,8 +302,12 @@ impl ProjectWorkspace {
) -> Result<WorkspaceBuildScripts> {
match self {
ProjectWorkspace::Cargo { cargo, toolchain, .. } => {
WorkspaceBuildScripts::run(config, cargo, progress, toolchain).with_context(|| {
format!("Failed to run build scripts for {}", &cargo.workspace_root().display())
WorkspaceBuildScripts::run_for_workspace(config, cargo, progress, toolchain)
.with_context(|| {
format!(
"Failed to run build scripts for {}",
&cargo.workspace_root().display()
)
})
}
ProjectWorkspace::Json { .. } | ProjectWorkspace::DetachedFiles { .. } => {
@ -311,6 +316,49 @@ impl ProjectWorkspace {
}
}
/// Runs the build scripts for the given [`ProjectWorkspace`]s. Depending on the invocation
/// strategy this may run a single build process for all project workspaces.
pub fn run_all_build_scripts(
workspaces: &[ProjectWorkspace],
config: &CargoConfig,
progress: &dyn Fn(String),
) -> Vec<Result<WorkspaceBuildScripts>> {
if let InvocationStrategy::PerWorkspaceWithManifestPath | InvocationStrategy::PerWorkspace =
config.invocation_strategy
{
return workspaces.iter().map(|it| it.run_build_scripts(config, progress)).collect();
}
let cargo_ws: Vec<_> = workspaces
.iter()
.filter_map(|it| match it {
ProjectWorkspace::Cargo { cargo, .. } => Some(cargo),
_ => None,
})
.collect();
let ref mut outputs = match WorkspaceBuildScripts::run_once(config, &cargo_ws, progress) {
Ok(it) => Ok(it.into_iter()),
// io::Error is not Clone?
Err(e) => Err(Arc::new(e)),
};
workspaces
.iter()
.map(|it| match it {
ProjectWorkspace::Cargo { cargo, .. } => match outputs {
Ok(outputs) => Ok(outputs.next().unwrap()),
Err(e) => Err(e.clone()).with_context(|| {
format!(
"Failed to run build scripts for {}",
&cargo.workspace_root().display()
)
}),
},
_ => Ok(WorkspaceBuildScripts::default()),
})
.collect()
}
pub fn set_build_scripts(&mut self, bs: WorkspaceBuildScripts) {
match self {
ProjectWorkspace::Cargo { build_scripts, .. } => *build_scripts = bs,

View file

@ -69,6 +69,14 @@ config_data! {
cargo_autoreload: bool = "true",
/// Run build scripts (`build.rs`) for more precise code analysis.
cargo_buildScripts_enable: bool = "true",
/// Specifies the invocation strategy to use when running the build scripts command.
/// If `per_workspace_with_manifest_path` is set, the command will be executed for each
/// workspace, `--manifest-path {workspace-dir}` will be passed to the invoked command and
/// the command will be executed from the project root.
/// If `per_workspace` is set, the command will be executed for each workspace and the
/// command will be executed from the corresponding workspace root.
/// If `once_in_root` is set, the command will be executed once in the project root.
cargo_buildScripts_invocationStrategy: InvocationStrategy = "\"per_workspace\"",
/// Override the command rust-analyzer uses to run build scripts and
/// build procedural macros. The command is required to output json
/// and should therefore include `--message-format=json` or a similar
@ -1056,6 +1064,13 @@ impl Config {
rustc_source,
unset_test_crates: UnsetTestCrates::Only(self.data.cargo_unsetTest.clone()),
wrap_rustc_in_build_scripts: self.data.cargo_buildScripts_useRustcWrapper,
invocation_strategy: match self.data.cargo_buildScripts_invocationStrategy {
InvocationStrategy::OnceInRoot => project_model::InvocationStrategy::OnceInRoot,
InvocationStrategy::PerWorkspaceWithManifestPath => {
project_model::InvocationStrategy::PerWorkspaceWithManifestPath
}
InvocationStrategy::PerWorkspace => project_model::InvocationStrategy::PerWorkspace,
},
run_build_script_command: self.data.cargo_buildScripts_overrideCommand.clone(),
extra_env: self.data.cargo_extraEnv.clone(),
}
@ -1587,6 +1602,14 @@ enum CargoFeaturesDef {
Selected(Vec<String>),
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
enum InvocationStrategy {
OnceInRoot,
PerWorkspaceWithManifestPath,
PerWorkspace,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged)]
enum LifetimeElisionDef {
@ -2001,6 +2024,15 @@ fn field_props(field: &str, ty: &str, doc: &[&str], default: &str) -> serde_json
"Render annotations above the whole item, including documentation comments and attributes."
],
},
"InvocationStrategy" => set! {
"type": "string",
"enum": ["per_workspace", "per_workspace_with_manifest_path", "once_in_root"],
"enumDescriptions": [
"The command will be executed for each workspace, `--manifest-path {workspace-dir}` will be passed to the invoked command and the command will be executed from the project root.",
"The command will be executed for each workspace and the command will be executed from the corresponding workspace root.",
"The command will be executed once in the project root."
],
},
_ => panic!("missing entry for {}: {}", ty, default),
}

View file

@ -175,10 +175,8 @@ impl GlobalState {
sender.send(Task::FetchBuildData(BuildDataProgress::Report(msg))).unwrap()
}
};
let mut res = Vec::new();
for ws in workspaces.iter() {
res.push(ws.run_build_scripts(&config, &progress));
}
let res = ProjectWorkspace::run_all_build_scripts(&workspaces, &config, &progress);
sender.send(Task::FetchBuildData(BuildDataProgress::End((workspaces, res)))).unwrap();
});
}

View file

@ -24,6 +24,17 @@ Automatically refresh project info via `cargo metadata` on
--
Run build scripts (`build.rs`) for more precise code analysis.
--
[[rust-analyzer.cargo.buildScripts.invocationStrategy]]rust-analyzer.cargo.buildScripts.invocationStrategy (default: `"per_workspace"`)::
+
--
Specifies the invocation strategy to use when running the build scripts command.
If `per_workspace_with_manifest_path` is set, the command will be executed for each
workspace, `--manifest-path {workspace-dir}` will be passed to the invoked command and
the command will be executed from the project root.
If `per_workspace` is set, the command will be executed for each workspace and the
command will be executed from the corresponding workspace root.
If `once_in_root` is set, the command will be executed once in the project root.
--
[[rust-analyzer.cargo.buildScripts.overrideCommand]]rust-analyzer.cargo.buildScripts.overrideCommand (default: `null`)::
+
--

View file

@ -421,6 +421,21 @@
"default": true,
"type": "boolean"
},
"rust-analyzer.cargo.buildScripts.invocationStrategy": {
"markdownDescription": "Specifies the invocation strategy to use when running the build scripts command.\nIf `per_workspace_with_manifest_path` is set, the command will be executed for each\nworkspace, `--manifest-path {workspace-dir}` will be passed to the invoked command and\nthe command will be executed from the project root.\nIf `per_workspace` is set, the command will be executed for each workspace and the\ncommand will be executed from the corresponding workspace root.\nIf `once_in_root` is set, the command will be executed once in the project root.",
"default": "per_workspace",
"type": "string",
"enum": [
"per_workspace",
"per_workspace_with_manifest_path",
"once_in_root"
],
"enumDescriptions": [
"The command will be executed for each workspace, `--manifest-path {workspace-dir}` will be passed to the invoked command and the command will be executed from the project root.",
"The command will be executed for each workspace and the command will be executed from the corresponding workspace root.",
"The command will be executed once in the project root."
]
},
"rust-analyzer.cargo.buildScripts.overrideCommand": {
"markdownDescription": "Override the command rust-analyzer uses to run build scripts and\nbuild procedural macros. The command is required to output json\nand should therefore include `--message-format=json` or a similar\noption.\n\nBy default, a cargo invocation will be constructed for the configured\ntargets and features, with the following base command line:\n\n```bash\ncargo check --quiet --workspace --message-format=json --all-targets\n```\n.",
"default": null,