diff --git a/Cargo.toml b/Cargo.toml index 2652ef9..3cc7847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,11 @@ anyhow = "1.0.100" [profile.release] debug = "full" strip = "none" + +[lib] +name = "cargo_subspace" +path = "src/lib.rs" + +[[bin]] +name = "cargo_subspace" +path = "src/main.rs" diff --git a/src/cli.rs b/src/cli.rs index 20eda2f..1609c20 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -29,40 +29,29 @@ pub struct CargoSubspace { pub command: SubspaceCommand, } -#[derive(PartialEq, Clone, Debug, Parser)] -pub struct FeatureArgs { - /// Activate all features in the workspace. - /// - /// Note that this flag applies to the whole workspace, not just the crate you're currently - /// working on. - #[arg(long, conflicts_with = "no_default_features")] - pub all_features: bool, - - /// Don't include default features during the workspace discovery process. - /// - /// Note that this flag applies to the whole workspace, not just the crate you're currently - /// working on. - #[arg(long, conflicts_with = "all_features")] - pub no_default_features: bool, -} - -#[derive(PartialEq, Clone, Debug, Parser)] -pub struct DiscoverArgs { - /// Profiles the discover process and writes a flamegraph to the given path - #[arg(long, hide = true)] - pub flamegraph: Option, - - #[command(flatten)] - pub feature_args: FeatureArgs, -} - #[derive(PartialEq, Clone, Debug, Parser)] pub enum SubspaceCommand { /// Print the cargo-subspace version and sysroot path and exit Version, Discover { - #[command(flatten)] - discover_args: DiscoverArgs, + /// Activate all features in the workspace. + /// + /// Note that this flag applies to the whole workspace, not just the crate you're currently + /// working on. + #[arg(long, conflicts_with = "no_default_features")] + all_features: bool, + + /// Don't include default features during the workspace discovery process. + /// + /// Note that this flag applies to the whole workspace, not just the crate you're currently + /// working on. + #[arg(long, conflicts_with = "all_features")] + no_default_features: bool, + + #[cfg(not(target_os = "windows"))] + /// Profiles the discover process and writes a flamegraph to the given path + #[arg(long, hide = true)] + flamegraph: Option, arg: DiscoverArgument, }, diff --git a/src/discover.rs b/src/discover.rs new file mode 100644 index 0000000..d131731 --- /dev/null +++ b/src/discover.rs @@ -0,0 +1,161 @@ +use std::{ + io::{BufRead, BufReader}, + process::Stdio, +}; + +use anyhow::Result; +use cargo_metadata::{Artifact, Message, Metadata, MetadataCommand, camino::Utf8PathBuf}; + +use crate::{ + graph::CrateGraph, + util::{self, FilePathBuf, Toolchain}, +}; + +pub struct DiscoverRunner { + toolchain: Toolchain, + features: FeatureOption, + manifest_path: FilePathBuf, +} + +impl DiscoverRunner { + pub fn new(toolchain: Toolchain, manifest_path: FilePathBuf) -> Self { + Self { + manifest_path, + toolchain, + features: FeatureOption::Default, + } + } + + pub fn with_all_features(mut self) -> Self { + self.features = FeatureOption::All; + self + } + + pub fn with_no_default_features(mut self) -> Self { + self.features = FeatureOption::NoDefault; + self + } + + pub fn with_default_features(mut self) -> Self { + self.features = FeatureOption::Default; + self + } + + /// Fetches the cargo metadata, constructs a crate graph, and prunes the graph such that it + /// only contains dependencies of the crate for the given manifest path + pub fn run(self) -> Result { + // Get the cargo workspace metadata + let metadata = self.get_metadata()?; + + // Lower the metadata into our internal crate graph representation + let mut graph = CrateGraph::from_metadata(metadata)?; + + // Prune the graph such that the remaining nodes are only those reachable from the node + // with the given manifest path + graph.prune(self.manifest_path.as_file_path())?; + + // Build the compile time dependencies (proc macros & build scripts) for the pruned graph + self.build_compile_time_dependencies(&mut graph)?; + + Ok(graph) + } + + fn get_metadata(&self) -> Result { + util::log_progress("Fetching metadata")?; + + let rustc_info = String::from_utf8(self.toolchain.rustc().arg("-vV").output()?.stdout)?; + let mut cmd = MetadataCommand::new(); + cmd.manifest_path(self.manifest_path.as_std_path()); + + if let Some(cargo_home) = self.toolchain.cargo_home.as_ref() { + cmd.cargo_path(cargo_home.join("bin/cargo")); + } + + let target_triple = rustc_info + .lines() + .find_map(|line| line.strip_prefix("host: ")); + if let Some(target_triple) = target_triple { + cmd.other_options(["--filter-platform".into(), target_triple.into()]); + } + + match self.features { + FeatureOption::All => { + cmd.features(cargo_metadata::CargoOpt::AllFeatures); + } + FeatureOption::NoDefault => { + cmd.features(cargo_metadata::CargoOpt::NoDefaultFeatures); + } + FeatureOption::Default => (), + } + + Ok(cmd.exec()?) + } + + fn build_compile_time_dependencies(&self, graph: &mut CrateGraph) -> Result<()> { + // TODO: check rust version to decide whether to use --compile-time-deps, which allows us to + // only build proc macros/build scripts during this step instead of building the whole crate + let child = self + .toolchain + .cargo() + // .arg("+nightly") + .arg("check") + // .arg("--compile-time-deps") + .arg("--quiet") + .arg("--message-format") + .arg("json") + .arg("--keep-going") + .arg("--all-targets") + .arg("--manifest-path") + .arg(self.manifest_path.as_std_path()) + // .arg("-Zunstable-options") + // .env("__CARGO_TEST_CHANNEL_OVERRIDE_DO_NOT_USE_THIS", "nightly") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + + for line in BufReader::new(child.stdout.unwrap()).lines() { + let line = line?; + let message = serde_json::from_str::(&line)?; + + match message { + Message::CompilerArtifact(Artifact { + filenames, + target, + package_id, + .. + }) => { + if let Some(dylib) = filenames.into_iter().find(is_dylib) + && target.is_proc_macro() + { + util::log_progress(format!("proc-macro {} built", target.name))?; + if let Some(pkg) = graph.get_mut(&package_id) { + pkg.proc_macro_dylib = Some(dylib.try_into()?); + } + } + } + Message::BuildScriptExecuted(script) => { + if let Some(pkg) = graph.get_mut(&script.package_id) { + util::log_progress(format!("build script {} run", pkg.name))?; + pkg.build_script = Some(script); + } + } + _ => (), + } + } + + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FeatureOption { + NoDefault, + All, + Default, +} + +fn is_dylib(path: &Utf8PathBuf) -> bool { + path.extension() + .map(|ext| ["dylib", "so", "dll"].contains(&ext)) + .unwrap_or(false) +} diff --git a/src/graph.rs b/src/graph.rs new file mode 100644 index 0000000..44be32e --- /dev/null +++ b/src/graph.rs @@ -0,0 +1,264 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::Result; +use cargo_metadata::{BuildScript, Edition, Metadata, PackageId, semver::Version}; + +use crate::{ + rust_project::{BuildInfo, Crate, CrateSource, Dep, TargetKind}, + util::{FilePath, FilePathBuf}, +}; + +pub struct CrateGraph { + pub inner: HashMap, +} + +impl CrateGraph { + pub fn from_metadata(metadata: Metadata) -> Result { + let mut inner = HashMap::new(); + let workspace_members: HashSet<&PackageId> = + HashSet::from_iter(metadata.workspace_members.iter()); + let mut features: HashMap> = HashMap::new(); + let mut dependencies: HashMap> = HashMap::new(); + + if let Some(it) = metadata.resolve { + for node in it.nodes { + features + .entry(node.id.clone()) + .or_default() + .extend(node.features.iter().map(|feat| feat.to_string())); + + // TODO: test that this works with renamed dependencies + dependencies + .entry(node.id) + .or_default() + .extend(node.deps.into_iter().map(|dep| Dependency { + id: dep.pkg, + name: dep.name, + })); + } + } + + for mut package in metadata.packages { + // If the package is not a member of the workspace, don't include any test, example, or + // bench targets. + if !workspace_members.contains(&package.id) { + package + .targets + .retain(|t| !t.is_test() && !t.is_example() && !t.is_bench()); + } + + let targets = package + .targets + .into_iter() + .map(|t| { + Ok(Target { + name: t.name, + edition: t.edition, + kind: t.kind, + root_module: t.src_path.try_into()?, + }) + }) + .collect::>>()?; + + let node = PackageNode { + name: package.name.to_string(), + targets, + manifest_path: package.manifest_path.try_into()?, + version: package.version, + is_workspace_member: workspace_members.contains(&package.id), + repository: package.repository, + features: features + .get(&package.id) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(), + dependencies: dependencies.get(&package.id).cloned().unwrap_or_default(), + proc_macro_dylib: None, + build_script: None, + }; + + inner.insert(package.id, node); + } + + Ok(Self { inner }) + } + + pub fn get_mut(&mut self, package_id: &PackageId) -> Option<&mut PackageNode> { + self.inner.get_mut(package_id) + } + + /// Prunes the graph such that the remaining nodes consist only of: + /// 1. The package with the given manifest path; and + /// 2. The dependencies of that package + pub fn prune(&mut self, manifest_path: FilePath<'_>) -> Result<()> { + let abs = std::path::absolute(manifest_path.as_std_path())?; + let Some((id, _)) = self + .inner + .iter() + .find(|(_, node)| node.manifest_path.as_std_path() == abs) + else { + anyhow::bail!( + "Could not find workspace member with manifest path {}", + manifest_path.as_ref().display() + ) + }; + + let mut filtered_packages: HashSet = HashSet::default(); + let mut stack = vec![id]; + + while let Some(id) = stack.pop() { + let Some(pkg) = self.inner.get(id) else { + continue; + }; + + for descendant in pkg.dependencies.iter() { + if !filtered_packages.contains(&descendant.id) { + stack.push(&descendant.id); + } + } + + filtered_packages.insert(id.clone()); + } + + self.inner.retain(|id, _| filtered_packages.contains(id)); + + Ok(()) + } + + pub fn into_crates(self) -> Result> { + let mut crates = Vec::new(); + let mut deps = Vec::new(); + let mut indexes: HashMap = HashMap::new(); + + for (id, package) in self.inner.into_iter() { + // Represents the indices of the `crates` array corresponding to lib targets for this + // package + let lib_indices: Vec<_> = package + .targets + .iter() + .enumerate() + .filter(|(_, target)| matches!(TargetKind::new(&target.kind), TargetKind::Lib)) + .map(|(i, target)| { + // I *think* this is the right way to handle target names in this + // context... + (crates.len() + i, target.name.clone().replace('-', "_")) + }) + .collect(); + + let mut env = HashMap::new(); + let mut include_dirs = vec![package.manifest_path.parent().unwrap().to_string()]; + if let Some(script) = package.build_script { + env.insert("OUT_DIR".into(), script.out_dir.to_string()); + + if let Some(parent) = script.out_dir.parent() { + include_dirs.push(parent.to_string()); + env.extend(script.env.clone().into_iter()); + } + } + + for target in package.targets { + let target_kind = TargetKind::new(&target.kind); + if matches!(target_kind, TargetKind::Lib) { + indexes.insert(id.clone(), crates.len()); + } + + // If the target is a bin or a test, we want to include all the lib targets of the + // package in the dependencies for this target. This is what gives bin/test targets + // access to the public items defined in lib targets in the same crate + let mut this_deps = vec![]; + if !matches!(target_kind, TargetKind::Lib) { + for (crate_index, name) in lib_indices.clone().into_iter() { + this_deps.push(Dep { crate_index, name }); + } + } + + deps.push(package.dependencies.clone()); + + crates.push(Crate { + display_name: Some(package.name.to_string().replace('-', "_")), + root_module: target.root_module.clone(), + edition: target.edition, + version: Some(package.version.to_string()), + deps: this_deps, + is_workspace_member: package.is_workspace_member, + is_proc_macro: target.is_proc_macro(), + repository: package.repository.clone(), + build: Some(BuildInfo { + label: target.name.clone(), + build_file: package.manifest_path.to_string(), + target_kind, + }), + proc_macro_dylib_path: package.proc_macro_dylib.clone(), + source: Some(CrateSource { + include_dirs: include_dirs.clone(), + exclude_dirs: vec![".git".into(), "target".into()], + }), + // cfg_groups: None, + cfg: package + .features + .clone() + .into_iter() + .map(|feature| format!("feature=\"{feature}\"")) + .collect(), + target: None, + env: env.clone(), + proc_macro_cwd: package + .manifest_path + .as_file_path() + .parent() + .map(|a| a.into()), + }); + } + } + + for (c, deps) in crates.iter_mut().zip(deps.into_iter()) { + c.deps.extend(deps.into_iter().map(|dep| Dep { + name: dep.name, + crate_index: indexes.get(&dep.id).copied().unwrap(), + })); + + // *shrug* buck does this, not sure if it's necessary + c.deps.sort_by_key(|dep| dep.crate_index); + } + + Ok(crates) + } +} + +/// Represents one target of a single package +#[derive(Clone)] +pub struct PackageNode { + pub name: String, + pub targets: Vec, + pub manifest_path: FilePathBuf, + pub version: Version, + pub is_workspace_member: bool, + pub repository: Option, + pub features: Vec, + pub dependencies: Vec, + pub build_script: Option, + pub proc_macro_dylib: Option, +} + +#[derive(Clone)] +pub struct Dependency { + pub id: PackageId, + pub name: String, +} + +#[derive(Clone)] +pub struct Target { + pub name: String, + pub edition: Edition, + pub kind: Vec, + pub root_module: FilePathBuf, +} + +impl Target { + fn is_proc_macro(&self) -> bool { + self.kind + .iter() + .any(|k| matches!(k, cargo_metadata::TargetKind::ProcMacro)) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1a63e9b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,76 @@ +pub mod cli; +mod discover; +mod graph; +mod rust_project; +pub mod util; + +use std::path::PathBuf; +use std::process::Stdio; + +use anyhow::{Result, anyhow}; +use cargo_metadata::camino::Utf8PathBuf; +use tracing::debug; + +use crate::cli::CheckArgs; +use crate::util::{FilePathBuf, Toolchain}; + +pub use discover::DiscoverRunner; +pub use rust_project::ProjectJson; + +pub fn check(command: &'static str, args: CheckArgs, cargo_home: Option) -> Result<()> { + let manifest = find_manifest(args.path.into())?; + let message_format = if util::is_tty() { + "--message-format=human" + } else if args.disable_color_diagnostics { + "--message-format=json" + } else { + "--message-format=json-diagnostic-rendered-ansi" + }; + + let mut cmd = Toolchain::new(cargo_home.clone()).cargo(); + + cmd.arg(command) + .arg(message_format) + .arg("--keep-going") + .arg("--all-targets") + .arg("--manifest-path") + .arg(manifest.as_file_path()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + for arg in args.passthrough_args { + cmd.arg(arg); + } + + let status = cmd.spawn()?.wait()?; + + if status.success() { + Ok(()) + } else { + Err(anyhow!("Failed to run check")) + } +} + +pub fn find_manifest(path: Utf8PathBuf) -> Result { + let path = std::path::absolute(&path)?; + let Some(parent) = path.parent() else { + anyhow::bail!("Invalid path: could not get parent"); + }; + + for ancestor in parent.ancestors() { + for item in std::fs::read_dir(ancestor)? { + let item = item?; + if item.file_type()?.is_file() && item.file_name() == "Cargo.toml" { + let path = std::path::absolute(item.path())?; + debug!(manifest_path = %path.display()); + + return path.try_into(); + } + } + } + + Err(anyhow!( + "Could not find manifest for path `{}`", + path.display() + )) +} diff --git a/src/main.rs b/src/main.rs index 9e88a82..75c3766 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,22 @@ -mod cli; -mod proc_macros; -mod rust_project; -mod util; - -use std::env; -use std::fs::{self, File}; -use std::io::{self, IsTerminal}; -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::time::Instant; +use std::{ + env, + fs::{self, File}, + io::{self, IsTerminal}, + path::PathBuf, + time::Instant, +}; use anyhow::{Result, anyhow}; -use cargo_metadata::MetadataCommand; use cargo_metadata::camino::Utf8PathBuf; +use cargo_subspace::{DiscoverRunner, ProjectJson, check, find_manifest}; +use cargo_subspace::{ + cli::{CargoSubspace, DiscoverArgument, DiscoverProjectData, SubspaceCommand}, + util::{self, Toolchain}, +}; use clap::Parser; -use rust_project::ProjectJson; -use tracing::level_filters::LevelFilter; -use tracing::{debug, error, info}; +use tracing::{debug, error, level_filters::LevelFilter}; use tracing_appender::non_blocking::WorkerGuard; -use crate::cli::{CheckArgs, DiscoverArgs, FeatureArgs}; -use crate::rust_project::compute_project_json; -use crate::util::{FilePath, FilePathBuf}; -use cli::{CargoSubspace, DiscoverArgument, DiscoverProjectData, SubspaceCommand}; - const DEFAULT_LOG_LOCATION: &str = ".local/state/cargo-subspace"; const LOG_FILE_NAME: &str = "cargo-subspace.log"; @@ -31,14 +24,14 @@ fn main() -> Result<()> { let command = env::args().collect::>(); let args = CargoSubspace::parse(); - let _tracing_guard = set_up_tracing(&args)?; + let _tracing_guard = set_up_tracing(args.log_location.clone(), args.verbose)?; let version = version(); let path = env::var("PATH")?; let dir = env::current_dir()?; debug!(path, cwd = %dir.display(), %version, ?command, ?args); - main_inner(args).inspect_err(|e| { + run_inner(args.command, args.cargo_home).inspect_err(|e| { error!("{e}"); let error = DiscoverProjectData::Error { @@ -50,76 +43,101 @@ fn main() -> Result<()> { }) } -struct Context { - cargo_home: Option, - is_tty: bool, -} - -impl Context { - fn log_progress(&self, message: T) -> Result<()> - where - T: Into, - { - let message = message.into(); - - if self.is_tty { - info!("{message}"); - } else { - let progress = DiscoverProjectData::Progress { message }; - println!("{}", serde_json::to_string(&progress)?); - } - - Ok(()) - } - - fn cargo(&self) -> Command { - self.toolchain_command("cargo") - } - - fn rustc(&self) -> Command { - self.toolchain_command("rustc") - } - - fn toolchain_command(&self, command: &str) -> Command { - if let Some(cargo_home) = self.cargo_home.as_ref() { - Command::new(cargo_home.join("bin").join(command)) - } else { - Command::new(command) - } - } -} - -impl From<&CargoSubspace> for Context { - fn from(value: &CargoSubspace) -> Self { - let is_tty = io::stdout().is_terminal(); - Context { - cargo_home: value.cargo_home.clone(), - is_tty, - } - } -} - -fn main_inner(args: CargoSubspace) -> Result<()> { +fn run_inner(command: SubspaceCommand, cargo_home: Option) -> Result<()> { let execution_start = Instant::now(); - let ctx: Context = (&args).into(); - match args.command { + match command { SubspaceCommand::Version => { println!("{}", version()); } - SubspaceCommand::Discover { discover_args, arg } => match arg { - DiscoverArgument::Path(path) => { - ctx.log_progress("Looking for manifest path")?; - let manifest_path = find_manifest(path)?; - - discover(&ctx, discover_args, manifest_path.as_file_path())?; - } - DiscoverArgument::Buildfile(manifest_path) => { - discover(&ctx, discover_args, manifest_path.as_file_path())? + SubspaceCommand::Discover { + all_features, + no_default_features, + #[cfg(not(target_os = "windows"))] + mut flamegraph, + arg, + } => { + #[cfg(not(target_os = "windows"))] + let pprof_guard = { + flamegraph + .take() + .map(|path| { + Ok::<_, anyhow::Error>(( + pprof::ProfilerGuardBuilder::default() + .frequency(100000) + .blocklist(&["libc", "libgcc", "pthread", "vdso"]) + .build()?, + path, + )) + }) + .transpose()? + }; + + let toolchain = Toolchain::new(cargo_home); + let manifest_path = match arg { + DiscoverArgument::Path(path) => find_manifest(path)?, + DiscoverArgument::Buildfile(manifest_path) => manifest_path, + }; + + let mut runner = DiscoverRunner::new(toolchain.clone(), manifest_path.clone()); + runner = match (all_features, no_default_features) { + (false, false) => runner.with_default_features(), + (true, false) => runner.with_all_features(), + (false, true) => runner.with_no_default_features(), + (true, true) => unreachable!("disallowed by clap"), + }; + + let crates = runner.run()?.into_crates()?; + + let p: PathBuf = String::from_utf8( + toolchain + .rustc() + .arg("--print") + .arg("sysroot") + .output()? + .stdout, + )? + .trim() + .into(); + + let sysroot = Utf8PathBuf::from_path_buf(p) + .map_err(|_| anyhow!("Path contains non-UTF-8 characters"))?; + let sysroot_src = sysroot.join("lib/rustlib/src/rust/library"); + + let project = ProjectJson { + sysroot, + sysroot_src: Some(sysroot_src), + // TODO: do i need this? buck excludes it... + // sysroot_project: None, + // TODO: do i need this? buck excludes it... + // cfg_groups: HashMap::new(), + crates, + // TODO: Add support for runnables + runnables: vec![], + }; + + let output = DiscoverProjectData::Finished { + buildfile: manifest_path.to_path_buf(), + project, + }; + let json = if util::is_tty() { + serde_json::to_string_pretty(&output)? + } else { + serde_json::to_string(&output)? + }; + + println!("{json}"); + + #[cfg(not(target_os = "windows"))] + if let Some((guard, path)) = pprof_guard { + let report = guard.report().build()?; + let file = std::fs::File::create(path)?; + + report.flamegraph(file)?; } - }, - SubspaceCommand::Check { args } => check(&ctx, "check", args)?, - SubspaceCommand::Clippy { args } => check(&ctx, "clippy", args)?, + } + SubspaceCommand::Check { args } => check("check", args, cargo_home)?, + SubspaceCommand::Clippy { args } => check("clippy", args, cargo_home)?, } debug!(execution_time_seconds = execution_start.elapsed().as_secs_f32()); @@ -127,9 +145,13 @@ fn main_inner(args: CargoSubspace) -> Result<()> { Ok(()) } -fn set_up_tracing(args: &CargoSubspace) -> Result> { +fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +fn set_up_tracing(log_location: Option, verbose: bool) -> Result> { if io::stdout().is_terminal() { - let level = if args.verbose { + let level = if verbose { LevelFilter::DEBUG } else { LevelFilter::INFO @@ -143,12 +165,11 @@ fn set_up_tracing(args: &CargoSubspace) -> Result> { let home: PathBuf = env::var("HOME")?.into(); #[cfg(target_os = "windows")] let home: PathBuf = env::var("USERPROFILE")?.into(); - let log_location = args - .log_location + let log_location = log_location .clone() .unwrap_or_else(|| home.join(DEFAULT_LOG_LOCATION)); - let level = if args.verbose { + let level = if verbose { LevelFilter::DEBUG } else { LevelFilter::WARN @@ -172,120 +193,3 @@ fn set_up_tracing(args: &CargoSubspace) -> Result> { Ok(Some(guard)) } } - -fn version() -> &'static str { - env!("CARGO_PKG_VERSION") -} - -fn discover(ctx: &Context, discover_args: DiscoverArgs, manifest_path: FilePath<'_>) -> Result<()> { - let rustc_info = String::from_utf8(ctx.rustc().arg("-vV").output()?.stdout)?; - let target_triple = rustc_info - .lines() - .find_map(|line| line.strip_prefix("host: ")); - ctx.log_progress("Fetching metadata")?; - let mut cmd = MetadataCommand::new(); - cmd.manifest_path(manifest_path); - - if let Some(cargo_home) = ctx.cargo_home.as_ref() { - cmd.cargo_path(cargo_home.join("bin/cargo")); - } - - if let Some(target_triple) = target_triple { - cmd.other_options(["--filter-platform".into(), target_triple.into()]); - } - - match discover_args.feature_args { - FeatureArgs { - all_features: true, - no_default_features: false, - } => { - cmd.features(cargo_metadata::CargoOpt::AllFeatures); - } - FeatureArgs { - all_features: false, - no_default_features: true, - } => { - cmd.features(cargo_metadata::CargoOpt::NoDefaultFeatures); - } - FeatureArgs { - all_features: false, - no_default_features: false, - } => (), - _ => unreachable!("prevented by clap's conflicts_with_all"), - } - - let metadata = cmd.exec()?; - let project = compute_project_json(ctx, discover_args, metadata, manifest_path)?; - - let output = DiscoverProjectData::Finished { - buildfile: manifest_path.to_path_buf(), - project, - }; - let json = if ctx.is_tty { - serde_json::to_string_pretty(&output)? - } else { - serde_json::to_string(&output)? - }; - - println!("{json}"); - - Ok(()) -} - -fn check(ctx: &Context, command: &'static str, args: CheckArgs) -> Result<()> { - let manifest = find_manifest(args.path.into())?; - let message_format = if ctx.is_tty { - "--message-format=human" - } else if args.disable_color_diagnostics { - "--message-format=json" - } else { - "--message-format=json-diagnostic-rendered-ansi" - }; - - let mut cmd = ctx.cargo(); - - cmd.arg(command) - .arg(message_format) - .arg("--keep-going") - .arg("--all-targets") - .arg("--manifest-path") - .arg(manifest.as_file_path()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - - for arg in args.passthrough_args { - cmd.arg(arg); - } - - let status = cmd.spawn()?.wait()?; - - if status.success() { - Ok(()) - } else { - Err(anyhow!("Failed to run check")) - } -} - -fn find_manifest(path: Utf8PathBuf) -> Result { - let path = std::path::absolute(&path)?; - let Some(parent) = path.parent() else { - anyhow::bail!("Invalid path: could not get parent"); - }; - - for ancestor in parent.ancestors() { - for item in std::fs::read_dir(ancestor)? { - let item = item?; - if item.file_type()?.is_file() && item.file_name() == "Cargo.toml" { - let path = std::path::absolute(item.path())?; - debug!(manifest_path = %path.display()); - - return path.try_into(); - } - } - } - - Err(anyhow!( - "Could not find manifest for path `{}`", - path.display() - )) -} diff --git a/src/proc_macros.rs b/src/proc_macros.rs deleted file mode 100644 index d4a2ca7..0000000 --- a/src/proc_macros.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::collections::HashMap; -use std::io::{BufRead, BufReader}; -use std::process::Stdio; - -use anyhow::Result; -use cargo_metadata::camino::Utf8PathBuf; -use cargo_metadata::{Artifact, BuildScript, Message, PackageId}; - -use crate::Context; -use crate::rust_project::PackageNode; -use crate::util::{FilePath, FilePathBuf}; - -pub(crate) fn build_compile_time_dependencies( - ctx: &Context, - manifest_path: FilePath<'_>, - names: &HashMap, -) -> Result<( - HashMap, - HashMap, -)> { - // TODO: check rust version to decide whether to use --compile-time-deps, which allows us to - // only build proc macros/build scripts during this step instead of building the whole crate - let child = ctx - .cargo() - // .arg("+nightly") - .arg("check") - // .arg("--compile-time-deps") - .arg("--quiet") - .arg("--message-format") - .arg("json") - .arg("--keep-going") - .arg("--all-targets") - .arg("--manifest-path") - .arg(manifest_path) - // .arg("-Zunstable-options") - // .env("__CARGO_TEST_CHANNEL_OVERRIDE_DO_NOT_USE_THIS", "nightly") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn()?; - - let mut dylibs = HashMap::new(); - let mut build_scripts = HashMap::new(); - - for line in BufReader::new(child.stdout.unwrap()).lines() { - let line = line?; - let message = serde_json::from_str::(&line)?; - - match message { - Message::CompilerArtifact(Artifact { - filenames, - target, - package_id, - .. - }) => { - if let Some(dylib) = filenames.into_iter().find(is_dylib) - && target.is_proc_macro() - { - ctx.log_progress(format!("proc-macro {} built", target.name))?; - - dylibs.insert(package_id, dylib.try_into()?); - } - } - Message::BuildScriptExecuted(script) => { - if let Some(pkg) = names.get(&script.package_id) { - ctx.log_progress(format!("build script {} run", pkg.name))?; - } else { - ctx.log_progress("build script run")?; - } - - build_scripts.insert(script.package_id.clone(), script); - } - _ => (), - } - } - - Ok((dylibs, build_scripts)) -} - -fn is_dylib(path: &Utf8PathBuf) -> bool { - path.extension() - .map(|ext| ["dylib", "so", "dll"].contains(&ext)) - .unwrap_or(false) -} diff --git a/src/rust_project.rs b/src/rust_project.rs index 2d92510..f3b226a 100644 --- a/src/rust_project.rs +++ b/src/rust_project.rs @@ -1,21 +1,14 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fmt::Display; -use std::path::PathBuf; -use anyhow::{Result, anyhow}; +use cargo_metadata::Edition; use cargo_metadata::camino::Utf8PathBuf; -use cargo_metadata::semver::Version; -use cargo_metadata::{BuildScript, Edition, Metadata, PackageId}; use serde::Serialize; -use tracing::debug; -use crate::Context; -use crate::cli::DiscoverArgs; -use crate::proc_macros::build_compile_time_dependencies; -use crate::util::{FilePath, FilePathBuf}; +use crate::util::FilePathBuf; #[derive(Debug, Clone, Serialize)] -pub(crate) struct ProjectJson { +pub struct ProjectJson { /// Path to the sysroot directory. /// /// The sysroot is where rustc looks for the @@ -27,11 +20,11 @@ pub(crate) struct ProjectJson { /// To see the current value of sysroot, you /// can query rustc: /// - /// ``` + /// ```sh /// $ rustc --print sysroot /// /Users/yourname/.rustup/toolchains/stable-x86_64-apple-darwin /// ``` - sysroot: Utf8PathBuf, + pub sysroot: Utf8PathBuf, /// Path to the directory with *source code* of /// sysroot crates. /// @@ -50,7 +43,7 @@ pub(crate) struct ProjectJson { /// several different "sysroots" in one graph of /// crates. #[serde(skip_serializing_if = "Option::is_none")] - sysroot_src: Option, + pub sysroot_src: Option, // /// A ProjectJson describing the crates of the sysroot. // #[serde(skip_serializing_if = "Option::is_none")] // sysroot_project: Option>, @@ -64,7 +57,7 @@ pub(crate) struct ProjectJson { /// project. Must include all transitive /// dependencies as well as sysroot crate (libstd, /// libcore and such). - crates: Vec, + pub crates: Vec, /// Configuration for CLI commands. /// /// These are used for running and debugging binaries @@ -90,24 +83,24 @@ pub(crate) struct ProjectJson { /// "kind": "testOne" /// } /// ``` - runnables: Vec, + pub runnables: Vec, } #[derive(Debug, Clone, Serialize)] -pub(crate) struct Crate { +pub struct Crate { /// Optional crate name used for display purposes, /// without affecting semantics. See the `deps` /// key for semantically-significant crate names. - display_name: Option, + pub display_name: Option, /// Path to the root module of the crate. - root_module: FilePathBuf, + pub root_module: FilePathBuf, /// Edition of the crate. - edition: Edition, + pub edition: Edition, /// The version of the crate. Used for calculating /// the correct docs.rs URL. - version: Option, + pub version: Option, /// Dependencies - deps: Vec, + pub deps: Vec, /// Should this crate be treated as a member of /// current "workspace". /// @@ -119,7 +112,7 @@ pub(crate) struct Crate { /// library and 3rd party crates to enable /// performance optimizations (rust-analyzer /// assumes that non-member crates don't change). - is_workspace_member: bool, + pub is_workspace_member: bool, /// Optionally specify the (super)set of `.rs` /// files comprising this crate. /// @@ -136,7 +129,7 @@ pub(crate) struct Crate { /// rust-analyzer assumes that files from one /// source can't refer to files in another source. #[serde(skip_serializing_if = "Option::is_none")] - source: Option, + pub source: Option, // /// List of cfg groups this crate inherits. // /// // /// All cfg in these groups will be concatenated to @@ -146,48 +139,48 @@ pub(crate) struct Crate { /// The set of cfgs activated for a given crate, like /// `["unix", "feature=\"foo\"", "feature=\"bar\""]`. #[serde(skip_serializing_if = "Vec::is_empty")] - cfg: Vec, + pub cfg: Vec, /// Target tuple for this Crate. /// /// Used when running `rustc --print cfg` /// to get target-specific cfgs. #[serde(skip_serializing_if = "Option::is_none")] - target: Option, + pub target: Option, /// Environment variables, used for /// the `env!` macro #[serde(skip_serializing_if = "HashMap::is_empty")] - env: HashMap, + pub env: HashMap, /// Whether the crate is a proc-macro crate. - is_proc_macro: bool, + pub is_proc_macro: bool, /// For proc-macro crates, path to compiled /// proc-macro (.so file). #[serde(skip_serializing_if = "Option::is_none")] - proc_macro_dylib_path: Option, + pub proc_macro_dylib_path: Option, /// Repository, matching the URL that would be used /// in Cargo.toml. #[serde(skip_serializing_if = "Option::is_none")] - repository: Option, + pub repository: Option, /// Build-specific data about this crate. #[serde(skip_serializing_if = "Option::is_none")] - build: Option, + pub build: Option, #[serde(default)] - proc_macro_cwd: Option, + pub proc_macro_cwd: Option, } #[derive(Debug, Clone, Serialize)] -pub(crate) struct Runnable { +pub struct Runnable { /// The program invoked by the runnable. /// /// For example, this might be `cargo`, `buck`, or `bazel`. - program: String, + pub program: String, /// The arguments passed to `program`. - args: Vec, + pub args: Vec, /// The current working directory of the runnable. - cwd: String, + pub cwd: String, /// Used to decide what code lens to offer. /// /// `testOne`: This runnable will be used when the user clicks the 'Run Test' @@ -197,13 +190,13 @@ pub(crate) struct Runnable { /// `{label}` and `{test_id}`. `{label}` will be replaced /// with the `Build::label` and `{test_id}` will be replaced /// with the test name. - kind: RunnableKind, + pub kind: RunnableKind, } #[allow(unused)] #[derive(Debug, Clone, Serialize)] #[serde(into = "String")] -pub(crate) enum RunnableKind { +pub enum RunnableKind { TestOne, String(String), } @@ -224,23 +217,23 @@ impl From for String { } #[derive(Debug, Clone, Serialize)] -pub(crate) struct Dep { +pub struct Dep { /// Index of a crate in the `crates` array. #[serde(rename = "crate")] - crate_index: usize, + pub crate_index: usize, /// Name as should appear in the (implicit) /// `extern crate name` declaration. - name: String, + pub name: String, } #[derive(Debug, Clone, Serialize)] -pub(crate) struct CrateSource { - include_dirs: Vec, - exclude_dirs: Vec, +pub struct CrateSource { + pub include_dirs: Vec, + pub exclude_dirs: Vec, } #[derive(Debug, Clone, Serialize)] -pub(crate) struct BuildInfo { +pub struct BuildInfo { /// The name associated with this crate. /// /// This is determined by the build system that produced @@ -249,14 +242,14 @@ pub(crate) struct BuildInfo { /// /// Do not attempt to parse the contents of this string; it is a build system-specific /// identifier similar to `Crate::display_name`. - label: String, + pub label: String, /// Path corresponding to the build system-specific file defining the crate. - build_file: String, + pub build_file: String, /// The kind of target. /// /// This information is used to determine what sort /// of runnable codelens to provide, if any. - target_kind: TargetKind, + pub target_kind: TargetKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -289,346 +282,3 @@ impl TargetKind { TargetKind::Bin } } - -pub(crate) fn find_sysroot(ctx: &Context) -> Result { - let p: PathBuf = String::from_utf8(ctx.rustc().arg("--print").arg("sysroot").output()?.stdout)? - .trim() - .into(); - - Utf8PathBuf::from_path_buf(p).map_err(|_| anyhow!("Path contains non-UTF-8 characters")) -} - -pub(crate) fn compute_project_json( - ctx: &Context, - #[allow(unused)] discover_args: DiscoverArgs, - metadata: Metadata, - manifest_path: FilePath<'_>, -) -> Result { - ctx.log_progress("Finding sysroot")?; - let sysroot = find_sysroot(ctx)?; - debug!(sysroot = %sysroot); - - let sysroot_src = sysroot.join("lib/rustlib/src/rust/library"); - let crates = crates_from_metadata( - ctx, - #[cfg(not(target_os = "windows"))] - discover_args.flamegraph, - metadata, - manifest_path, - )?; - - Ok(ProjectJson { - sysroot, - sysroot_src: Some(sysroot_src), - // TODO: do i need this? buck excludes it... - // sysroot_project: None, - // TODO: do i need this? buck excludes it... - // cfg_groups: HashMap::new(), - crates, - // TODO: Add support for runnables - runnables: vec![], - }) -} - -/// Represents one target of a single package -#[derive(Clone)] -pub(crate) struct PackageNode { - pub(crate) name: String, - targets: Vec, - manifest_path: FilePathBuf, - version: Version, - is_workspace_member: bool, - repository: Option, - features: Vec, - dependencies: Vec, -} - -#[derive(Clone)] -pub(crate) struct Dependency { - id: PackageId, - name: String, -} - -#[derive(Clone)] -pub(crate) struct Target { - name: String, - edition: Edition, - kind: Vec, - root_module: FilePathBuf, -} - -impl Target { - fn is_proc_macro(&self) -> bool { - self.kind - .iter() - .any(|k| matches!(k, cargo_metadata::TargetKind::ProcMacro)) - } -} - -struct PackageGraph { - graph: HashMap, -} - -impl PackageGraph { - fn lower_from_metadata(metadata: Metadata) -> Result { - let mut graph = HashMap::new(); - let workspace_members: HashSet<&PackageId> = - HashSet::from_iter(metadata.workspace_members.iter()); - let mut features: HashMap> = HashMap::new(); - let mut dependencies: HashMap> = HashMap::new(); - - if let Some(it) = metadata.resolve { - for node in it.nodes { - features - .entry(node.id.clone()) - .or_default() - .extend(node.features.iter().map(|feat| feat.to_string())); - - // TODO: test that this works with renamed dependencies - dependencies - .entry(node.id) - .or_default() - .extend(node.deps.into_iter().map(|dep| Dependency { - id: dep.pkg, - name: dep.name, - })); - } - } - - for mut package in metadata.packages { - // If the package is not a member of the workspace, don't include any test, example, or - // bench targets. - if !workspace_members.contains(&package.id) { - package - .targets - .retain(|t| !t.is_test() && !t.is_example() && !t.is_bench()); - } - - let targets = package - .targets - .into_iter() - .map(|t| { - Ok(Target { - name: t.name, - edition: t.edition, - kind: t.kind, - root_module: t.src_path.try_into()?, - }) - }) - .collect::>>()?; - - let node = PackageNode { - name: package.name.to_string(), - targets, - manifest_path: package.manifest_path.try_into()?, - version: package.version, - is_workspace_member: workspace_members.contains(&package.id), - repository: package.repository, - features: features - .get(&package.id) - .cloned() - .unwrap_or_default() - .into_iter() - .collect(), - dependencies: dependencies.get(&package.id).cloned().unwrap_or_default(), - }; - - graph.insert(package.id, node); - } - - Ok(Self { graph }) - } - - /// Prunes the graph such that the remaining nodes consist only of: - /// 1. The package with the given manifest path; and - /// 2. The dependencies of that package - fn prune(&mut self, manifest_path: FilePath<'_>) -> Result<()> { - let abs = std::path::absolute(manifest_path.as_std_path())?; - let Some((id, _)) = self - .graph - .iter() - .find(|(_, node)| node.manifest_path.as_std_path() == abs) - else { - anyhow::bail!( - "Could not find workspace member with manifest path {}", - manifest_path.as_ref().display() - ) - }; - - let mut filtered_packages: HashSet = HashSet::default(); - let mut stack = vec![id]; - - while let Some(id) = stack.pop() { - let Some(pkg) = self.graph.get(id) else { - continue; - }; - - for descendant in pkg.dependencies.iter() { - if !filtered_packages.contains(&descendant.id) { - stack.push(&descendant.id); - } - } - - filtered_packages.insert(id.clone()); - } - - self.graph.retain(|id, _| filtered_packages.contains(id)); - - Ok(()) - } - - /// Lowers the graph to a vector of crates - fn lower_to_crates( - self, - proc_macro_dylibs: HashMap, - build_scripts: HashMap, - ) -> Result> { - let mut crates = Vec::new(); - let mut deps = Vec::new(); - let mut indexes: HashMap = HashMap::new(); - - for (id, package) in self.graph.into_iter() { - // Represents the indices of the `crates` array corresponding to lib targets for this - // package - let lib_indices: Vec<_> = package - .targets - .iter() - .enumerate() - .filter(|(_, target)| matches!(TargetKind::new(&target.kind), TargetKind::Lib)) - .map(|(i, target)| { - // I *think* this is the right way to handle target names in this - // context... - (crates.len() + i, target.name.clone().replace('-', "_")) - }) - .collect(); - - let mut env = HashMap::new(); - let mut include_dirs = vec![package.manifest_path.parent().unwrap().to_string()]; - if let Some(script) = build_scripts.get(&id) { - env.insert("OUT_DIR".into(), script.out_dir.to_string()); - - if let Some(parent) = script.out_dir.parent() { - include_dirs.push(parent.to_string()); - env.extend(script.env.clone().into_iter()); - } - } - - for target in package.targets { - let target_kind = TargetKind::new(&target.kind); - if matches!(target_kind, TargetKind::Lib) { - indexes.insert(id.clone(), crates.len()); - } - - // If the target is a bin or a test, we want to include all the lib targets of the - // package in the dependencies for this target. This is what gives bin/test targets - // access to the public items defined in lib targets in the same crate - let mut this_deps = vec![]; - if !matches!(target_kind, TargetKind::Lib) { - for (crate_index, name) in lib_indices.clone().into_iter() { - this_deps.push(Dep { crate_index, name }); - } - } - - deps.push(package.dependencies.clone()); - - crates.push(Crate { - display_name: Some(package.name.to_string().replace('-', "_")), - root_module: target.root_module.clone(), - edition: target.edition, - version: Some(package.version.to_string()), - deps: this_deps, - is_workspace_member: package.is_workspace_member, - is_proc_macro: target.is_proc_macro(), - repository: package.repository.clone(), - build: Some(BuildInfo { - label: target.name.clone(), - build_file: package.manifest_path.to_string(), - target_kind, - }), - proc_macro_dylib_path: proc_macro_dylibs.get(&id).cloned(), - source: Some(CrateSource { - include_dirs: include_dirs.clone(), - exclude_dirs: vec![".git".into(), "target".into()], - }), - // cfg_groups: None, - cfg: package - .features - .clone() - .into_iter() - .map(|feature| format!("feature=\"{feature}\"")) - .collect(), - target: None, - env: env.clone(), - proc_macro_cwd: package - .manifest_path - .as_file_path() - .parent() - .map(|a| a.into()), - }); - } - } - - for (c, deps) in crates.iter_mut().zip(deps.into_iter()) { - c.deps.extend(deps.into_iter().map(|dep| Dep { - name: dep.name, - crate_index: indexes.get(&dep.id).copied().unwrap(), - })); - - // *shrug* buck does this, not sure if it's necessary - c.deps.sort_by_key(|dep| dep.crate_index); - } - - Ok(crates) - } -} - -fn crates_from_metadata( - ctx: &Context, - #[cfg(not(target_os = "windows"))] flamegraph: Option, - metadata: Metadata, - manifest_path: FilePath<'_>, -) -> Result> { - #[cfg(not(target_os = "windows"))] - let pprof_guard = { - flamegraph - .as_ref() - .map(|path| { - Ok::<_, anyhow::Error>(( - pprof::ProfilerGuardBuilder::default() - .frequency(100000) - .blocklist(&["libc", "libgcc", "pthread", "vdso"]) - .build()?, - path, - )) - }) - .transpose()? - }; - - let mut graph = PackageGraph::lower_from_metadata(metadata)?; - let original_package_count = graph.graph.len(); - - ctx.log_progress("Pruning metadata")?; - graph.prune(manifest_path)?; - - debug!( - original_package_count, - new_package_count = graph.graph.len() - ); - - ctx.log_progress("Building proc macros")?; - let (proc_macro_dylibs, build_scripts) = - build_compile_time_dependencies(ctx, manifest_path, &graph.graph)?; - - ctx.log_progress("Constructing crate graph")?; - let crates = graph.lower_to_crates(proc_macro_dylibs, build_scripts)?; - - #[cfg(not(target_os = "windows"))] - if let Some((guard, path)) = pprof_guard { - let report = guard.report().build()?; - let file = std::fs::File::create(path)?; - - report.flamegraph(file)?; - } - - Ok(crates) -} diff --git a/src/util.rs b/src/util.rs index c331c5f..0d0fd5f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,22 +1,77 @@ use std::{ ffi::OsStr, fmt::Display, + io::{self, IsTerminal}, ops::Deref, path::{Path, PathBuf}, + process::Command, str::FromStr, }; -use anyhow::anyhow; +use anyhow::{Result, anyhow}; use cargo_metadata::camino::{Utf8Path, Utf8PathBuf}; use serde::{Deserialize, Deserializer, Serialize}; +use tracing::info; + +use crate::cli::DiscoverProjectData; + +#[derive(Default, Clone)] +pub struct Toolchain { + pub cargo_home: Option, +} + +impl Toolchain { + pub fn new(cargo_home: Option) -> Self { + Self { cargo_home } + } + + fn cargo_command(&self, cmd: &str) -> Command { + if let Some(cargo_home) = self.cargo_home.as_ref() { + Command::new(cargo_home.join("bin").join(cmd)) + } else { + Command::new(cmd) + } + } + + pub fn rustc(&self) -> Command { + self.cargo_command("rustc") + } + + pub fn cargo(&self) -> Command { + self.cargo_command("cargo") + } +} + +/// Returns true only if we are running in a terminal +pub fn is_tty() -> bool { + io::stdout().is_terminal() +} + +/// Emits a log message to stdout in the format expected by rust-analyzer. This log message is +/// displayed to users in their editor. +pub fn log_progress(message: T) -> Result<()> +where + T: Into, +{ + let message = message.into(); + + if is_tty() { + info!("{message}"); + } else { + let progress = DiscoverProjectData::Progress { message }; + println!("{}", serde_json::to_string(&progress)?); + } + + Ok(()) +} /// A wrapper around [`Path`] that can only store a file. #[derive(PartialEq, Clone, Copy, Debug)] #[repr(transparent)] -pub(crate) struct FilePath<'a>(&'a Utf8Path); +pub struct FilePath<'a>(&'a Utf8Path); impl FilePath<'_> { - pub(crate) fn parent(&self) -> Option> { + pub fn parent(&self) -> Option> { self.0.parent().map(FilePath) } } @@ -50,10 +105,10 @@ impl Deref for FilePath<'_> { /// A wrapper around [`PathBuf`] that can only store a file. #[derive(PartialEq, Clone, Debug, Serialize)] #[repr(transparent)] -pub(crate) struct FilePathBuf(Utf8PathBuf); +pub struct FilePathBuf(Utf8PathBuf); impl FilePathBuf { - pub(crate) fn as_file_path(&self) -> FilePath<'_> { + pub fn as_file_path(&self) -> FilePath<'_> { FilePath(self.0.as_path()) } }