diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 4014ec3778..7a931a2687 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml @@ -885,7 +885,7 @@ tasks: build_flags: - "--compile_one_dependency" build_targets: - - "tools/rust_analyzer/main.rs" + - "tools/rust_analyzer/bin/gen_rust_project.rs" extensions_bindgen_linux: platform: ubuntu2004 name: Extensions Bindgen diff --git a/.gitignore b/.gitignore index 0316dc9006..74ef480cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ user.bazelrc .vscode *.code-workspace +# zed +.zed + # JetBrains .idea .idea/** diff --git a/MODULE.bazel b/MODULE.bazel index 5171b23720..971d0867ba 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -22,6 +22,7 @@ use_repo( internal_deps, "rrra", "rrra__anyhow-1.0.71", + "rrra__camino-1.1.9", "rrra__clap-4.3.11", "rrra__env_logger-0.10.0", "rrra__itertools-0.11.0", diff --git a/docs/rust_analyzer.vm b/docs/rust_analyzer.vm index b9743320b5..e5f794b3f3 100644 --- a/docs/rust_analyzer.vm +++ b/docs/rust_analyzer.vm @@ -2,10 +2,12 @@ ## Overview For [non-Cargo projects](https://rust-analyzer.github.io/manual.html#non-cargo-based-projects), -[rust-analyzer](https://rust-analyzer.github.io/) depends on a `rust-project.json` file at the -root of the project that describes its structure. The `rust_analyzer` rule facilitates generating -such a file. +[rust-analyzer](https://rust-analyzer.github.io/) depends on either a `rust-project.json` file +at the root of the project that describes its structure or on build system specific +[project auto-discovery](https://rust-analyzer.github.io/manual.html#rust-analyzer.workspace.discoverConfig). +The `rust_analyzer` rules facilitate both approaches. +## rust-project.json approach ### Setup First, ensure `rules_rust` is setup in your workspace. By default, `rust_register_toolchains` will @@ -84,4 +86,71 @@ build --@rules_rust//rust/settings:rustc_output_diagnostics=true --output_groups Then you can use a prototype [rust-analyzer plugin](https://marketplace.visualstudio.com/items?itemName=MattStark.bazel-rust-analyzer) that automatically collects the outputs whenever you recompile. -]]# +## Project auto-discovery +### Setup + +Auto-discovery makes `rust-analyzer` behave in a Bazel project in a similar fashion to how it does +in a Cargo project. This is achieved by generating a structure similar to what `rust-project.json` +contains but, instead of writing that to a file, the data gets piped to `rust-analyzer` directly +through `stdout`. To use auto-discovery the `rust-analyzer` IDE settings must be configured similar to: + +```json +"rust-analyzer": { + "workspace": { + "discoverConfig": { + "command": ["discover_bazel_rust_project.sh"], + "progressLabel": "rust_analyzer", + "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel"] + } + } +} +``` + +The shell script passed to `discoverConfig.command` is typically meant to wrap the bazel rule invocation, +primarily for muting `stderr` (because `rust-analyzer` will consider that an error has occurred if anything +is passed through `stderr`) and, additionally, for specifying rule arguments. E.g: + +```shell +#!/usr/bin/bash + +bazel \ + run \ + @rules_rust//tools/rust_analyzer:discover_bazel_rust_project -- \ + --bazel_startup_option=--output_base=~/ide_bazel \ + --bazel_arg=--watchfs \ + ${1:+"$1"} 2>/dev/null +``` + +The script above also handles an optional CLI argument which gets passed when workspace splitting is +enabled. The script path should be either absolute or relative to the project root. + +### Workspace splitting + +The above configuration treats the entire project as a single workspace. However, large codebases might be +too much to handle for `rust-analyzer` all at once. This can be addressed by splitting the codebase in +multiple workspaces by extending the `discoverConfig.command` setting: + +```json +"rust-analyzer": { + "workspace": { + "discoverConfig": { + "command": ["discover_bazel_rust_project.sh", "{arg}"], + "progressLabel": "rust_analyzer", + "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel"] + } + } +} +``` + +`{arg}` acts as a placeholder that `rust-analyzer` replaces with the path of the source / build file +that gets opened. + +The root of the workspace will, in this configuration, be the package the crate currently being worked on +belongs to. This means that only that package and its dependencies get built and indexed by `rust-analyzer`, +thus allowing for a smaller footprint. + +`rust-analyzer` will switch workspaces whenever an out-of-tree file gets opened, essentially indexing that +crate and its dependencies separately. A caveat of this is that *dependents* of the crate currently being +worked on are not indexed and won't be tracked by `rust-analyzer`. + +]]# \ No newline at end of file diff --git a/extensions/prost/private/prost.bzl b/extensions/prost/private/prost.bzl index 61157e8f6c..c5917107b5 100644 --- a/extensions/prost/private/prost.bzl +++ b/extensions/prost/private/prost.bzl @@ -308,6 +308,8 @@ def _rust_prost_aspect_impl(target, ctx): # https://github.com/rust-analyzer/rust-analyzer/blob/2021-11-15/crates/project_model/src/workspace.rs#L529-L531 cfgs = ["test", "debug_assertions"] + build_info_out_dirs = [dep_variant_info.build_info.out_dir] if dep_variant_info.build_info != None and dep_variant_info.build_info.out_dir != None else None + rust_analyzer_info = write_rust_analyzer_spec_file(ctx, ctx.rule.attr, ctx.label, RustAnalyzerInfo( aliases = {}, crate = dep_variant_info.crate_info, @@ -315,7 +317,9 @@ def _rust_prost_aspect_impl(target, ctx): env = dep_variant_info.crate_info.rustc_env, deps = rust_analyzer_deps, crate_specs = depset(transitive = [dep.crate_specs for dep in rust_analyzer_deps]), - proc_macro_dylib_path = None, + proc_macro_dylibs = depset(transitive = [dep.proc_macro_dylibs for dep in rust_analyzer_deps]), + build_info_out_dirs = depset(direct = build_info_out_dirs, transitive = [dep.build_info_out_dirs for dep in rust_analyzer_deps]), + proc_macro_dylib = None, build_info = dep_variant_info.build_info, )) diff --git a/rust/private/providers.bzl b/rust/private/providers.bzl index 0556ebef5f..746370bc70 100644 --- a/rust/private/providers.bzl +++ b/rust/private/providers.bzl @@ -159,12 +159,14 @@ RustAnalyzerInfo = provider( fields = { "aliases": "Dict[RustAnalyzerInfo, String]: Replacement names these targets should be known as in Rust code", "build_info": "BuildInfo: build info for this crate if present", + "build_info_out_dirs": "Depset[File]: transitive closure of build script out dirs", "cfgs": "List[String]: features or other compilation `--cfg` settings", "crate": "CrateInfo: Crate information.", - "crate_specs": "Depset[File]: transitive closure of OutputGroupInfo files", + "crate_specs": "Depset[File]: transitive closure of crate spec files", "deps": "List[RustAnalyzerInfo]: direct dependencies", "env": "Dict[String: String]: Environment variables, used for the `env!` macro", - "proc_macro_dylib_path": "File: compiled shared library output of proc-macro rule", + "proc_macro_dylib": "File: if this is a proc-macro target, the shared library output", + "proc_macro_dylibs": "Depset[File]: transitive closure of proc-macro shared library files", }, ) diff --git a/rust/private/rust_analyzer.bzl b/rust/private/rust_analyzer.bzl index bc12306a82..a08d50ca4a 100644 --- a/rust/private/rust_analyzer.bzl +++ b/rust/private/rust_analyzer.bzl @@ -54,7 +54,9 @@ def write_rust_analyzer_spec_file(ctx, attrs, owner, base_info): env = base_info.env, deps = base_info.deps, crate_specs = depset(direct = [crate_spec], transitive = [base_info.crate_specs]), - proc_macro_dylib_path = base_info.proc_macro_dylib_path, + proc_macro_dylibs = depset(transitive = [base_info.proc_macro_dylibs]), + build_info_out_dirs = depset(transitive = [base_info.build_info_out_dirs]), + proc_macro_dylib = base_info.proc_macro_dylib, build_info = base_info.build_info, ) @@ -135,6 +137,10 @@ def _rust_analyzer_aspect_impl(target, ctx): if aliased_target.label in labels_to_rais: aliases[labels_to_rais[aliased_target.label]] = aliased_name + proc_macro_dylib = find_proc_macro_dylib(toolchain, target) + proc_macro_dylibs = [proc_macro_dylib] if proc_macro_dylib else None + build_info_out_dirs = [build_info.out_dir] if build_info != None and build_info.out_dir != None else None + rust_analyzer_info = write_rust_analyzer_spec_file(ctx, ctx.rule.attr, ctx.label, RustAnalyzerInfo( aliases = aliases, crate = crate_info, @@ -142,23 +148,29 @@ def _rust_analyzer_aspect_impl(target, ctx): env = crate_info.rustc_env, deps = dep_infos, crate_specs = depset(transitive = [dep.crate_specs for dep in dep_infos]), - proc_macro_dylib_path = find_proc_macro_dylib_path(toolchain, target), + proc_macro_dylibs = depset(direct = proc_macro_dylibs, transitive = [dep.proc_macro_dylibs for dep in dep_infos]), + build_info_out_dirs = depset(direct = build_info_out_dirs, transitive = [dep.build_info_out_dirs for dep in dep_infos]), + proc_macro_dylib = proc_macro_dylib, build_info = build_info, )) return [ rust_analyzer_info, - OutputGroupInfo(rust_analyzer_crate_spec = rust_analyzer_info.crate_specs), + OutputGroupInfo( + rust_analyzer_crate_spec = rust_analyzer_info.crate_specs, + rust_analyzer_proc_macro_dylib = rust_analyzer_info.proc_macro_dylibs, + rust_analyzer_src = rust_analyzer_info.build_info_out_dirs, + ), ] -def find_proc_macro_dylib_path(toolchain, target): - """Find the proc_macro_dylib_path of target. Returns None if target crate is not type proc-macro. +def find_proc_macro_dylib(toolchain, target): + """Find the proc_macro_dylib of target. Returns None if target crate is not type proc-macro. Args: toolchain: The current rust toolchain. target: The current target. Returns: - (path): The path to the proc macro dylib, or None if this crate is not a proc-macro. + (File): The path to the proc macro dylib, or None if this crate is not a proc-macro. """ if rust_common.crate_info in target: crate_info = target[rust_common.crate_info] @@ -174,7 +186,7 @@ def find_proc_macro_dylib_path(toolchain, target): for action in target.actions: for output in action.outputs.to_list(): if output.extension == dylib_ext[1:]: - return output.path + return output # Failed to find the dylib path inside a proc-macro crate. # TODO: Should this be an error? @@ -188,7 +200,7 @@ rust_analyzer_aspect = aspect( ) # Paths in the generated JSON file begin with one of these placeholders. -# The gen_rust_project driver will replace them with absolute paths. +# The `rust-analyzer` driver will replace them with absolute paths. _WORKSPACE_TEMPLATE = "__WORKSPACE__/" _EXEC_ROOT_TEMPLATE = "__EXEC_ROOT__/" _OUTPUT_BASE_TEMPLATE = "__OUTPUT_BASE__/" @@ -220,6 +232,7 @@ def _create_single_crate(ctx, attrs, info): crate["edition"] = info.crate.edition crate["env"] = {} crate["crate_type"] = info.crate.type + crate["is_test"] = info.crate.is_test # Switch on external/ to determine if crates are in the workspace or remote. # TODO: Some folks may want to override this for vendored dependencies. @@ -230,6 +243,14 @@ def _create_single_crate(ctx, attrs, info): crate["root_module"] = path_prefix + info.crate.root.path crate["source"] = {"exclude_dirs": [], "include_dirs": []} + # We're only interested in the build info for local crates as these are the + # only ones for which we want build file watching and code lens runnables support. + if not is_external and not is_generated: + crate["build"] = { + "build_file": _WORKSPACE_TEMPLATE + ctx.build_file_path, + "label": ctx.label.package + ":" + ctx.label.name, + } + if is_generated: srcs = getattr(ctx.rule.files, "srcs", []) src_map = {src.short_path: src for src in srcs if src.is_source} @@ -268,8 +289,8 @@ def _create_single_crate(ctx, attrs, info): crate["cfg"] = info.cfgs toolchain = find_toolchain(ctx) crate["target"] = (_EXEC_ROOT_TEMPLATE + toolchain.target_json.path) if toolchain.target_json else toolchain.target_flag_value - if info.proc_macro_dylib_path != None: - crate["proc_macro_dylib_path"] = _EXEC_ROOT_TEMPLATE + info.proc_macro_dylib_path + if info.proc_macro_dylib != None: + crate["proc_macro_dylib_path"] = _EXEC_ROOT_TEMPLATE + info.proc_macro_dylib.path return crate def _rust_analyzer_toolchain_impl(ctx): diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/BUILD.bazel b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/BUILD.bazel new file mode 100644 index 0000000000..c363ad776d --- /dev/null +++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/BUILD.bazel @@ -0,0 +1,42 @@ +load( + "@rules_rust//rust:defs.bzl", + "rust_shared_library", + "rust_static_library", + "rust_test", +) + +rust_shared_library( + name = "greeter_cdylib", + srcs = [ + "greeter.rs", + "shared_lib.rs", + ], + crate_root = "shared_lib.rs", + edition = "2018", +) + +rust_static_library( + name = "greeter_staticlib", + srcs = [ + "greeter.rs", + "static_lib.rs", + ], + crate_root = "static_lib.rs", + edition = "2018", +) + +rust_test( + name = "auto_discovery_json_test", + srcs = ["auto_discovery_json_test.rs"], + data = [":auto-discovery.json"], + edition = "2018", + env = {"AUTO_DISCOVERY_JSON": "$(rootpath :auto-discovery.json)"}, + # This target is tagged as manual since it's not expected to pass in + # contexts outside of `//test/rust_analyzer:rust_analyzer_test`. Run + # that target to execute this test. + tags = ["manual"], + deps = [ + "//test/rust_analyzer/3rdparty/crates:serde", + "//test/rust_analyzer/3rdparty/crates:serde_json", + ], +) diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/auto_discovery_json_test.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/auto_discovery_json_test.rs new file mode 100644 index 0000000000..e015b7cee2 --- /dev/null +++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/auto_discovery_json_test.rs @@ -0,0 +1,57 @@ +#[cfg(test)] +mod tests { + use serde::Deserialize; + use std::env; + use std::path::PathBuf; + + #[derive(Deserialize)] + #[serde(tag = "kind")] + #[serde(rename_all = "snake_case")] + enum DiscoverProject { + Finished { project: Project }, + Progress {}, + } + + #[derive(Deserialize)] + struct Project { + crates: Vec, + } + + #[derive(Deserialize)] + struct Crate { + display_name: String, + root_module: String, + } + + #[test] + fn test_static_and_shared_lib() { + let rust_project_path = PathBuf::from(env::var("AUTO_DISCOVERY_JSON").unwrap()); + let content = std::fs::read_to_string(&rust_project_path) + .unwrap_or_else(|_| panic!("couldn't open {:?}", &rust_project_path)); + println!("{}", content); + + for line in content.lines() { + let discovery: DiscoverProject = + serde_json::from_str(line).expect("Failed to deserialize discovery JSON"); + + let project = match discovery { + DiscoverProject::Finished { project } => project, + DiscoverProject::Progress {} => continue, + }; + + let cdylib = project + .crates + .iter() + .find(|c| &c.display_name == "greeter_cdylib") + .unwrap(); + assert!(cdylib.root_module.ends_with("/shared_lib.rs")); + + let staticlib = project + .crates + .iter() + .find(|c| &c.display_name == "greeter_staticlib") + .unwrap(); + assert!(staticlib.root_module.ends_with("/static_lib.rs")); + } + } +} diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/greeter.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/greeter.rs new file mode 100644 index 0000000000..ac87521090 --- /dev/null +++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/greeter.rs @@ -0,0 +1,61 @@ +/// Object that displays a greeting. +pub struct Greeter { + greeting: String, +} + +/// Implementation of Greeter. +impl Greeter { + /// Constructs a new `Greeter`. + /// + /// # Examples + /// + /// ``` + /// use hello_lib::greeter::Greeter; + /// + /// let greeter = Greeter::new("Hello"); + /// ``` + pub fn new(greeting: &str) -> Greeter { + Greeter { + greeting: greeting.to_string(), + } + } + + /// Returns the greeting as a string. + /// + /// # Examples + /// + /// ``` + /// use hello_lib::greeter::Greeter; + /// + /// let greeter = Greeter::new("Hello"); + /// let greeting = greeter.greeting("World"); + /// ``` + pub fn greeting(&self, thing: &str) -> String { + format!("{} {}", &self.greeting, thing) + } + + /// Prints the greeting. + /// + /// # Examples + /// + /// ``` + /// use hello_lib::greeter::Greeter; + /// + /// let greeter = Greeter::new("Hello"); + /// greeter.greet("World"); + /// ``` + pub fn greet(&self, thing: &str) { + println!("{} {}", &self.greeting, thing); + } +} + +#[cfg(test)] +mod test { + use super::Greeter; + + #[test] + fn test_greeting() { + let hello = Greeter::new("Hi"); + assert_eq!("Hi Rust", hello.greeting("Rust")); + } +} diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/shared_lib.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/shared_lib.rs new file mode 100644 index 0000000000..44969c66c7 --- /dev/null +++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/shared_lib.rs @@ -0,0 +1 @@ +pub mod greeter; diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/static_lib.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/static_lib.rs new file mode 100644 index 0000000000..44969c66c7 --- /dev/null +++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/static_lib.rs @@ -0,0 +1 @@ +pub mod greeter; diff --git a/test/rust_analyzer/rust_analyzer_test_runner.sh b/test/rust_analyzer/rust_analyzer_test_runner.sh index 44c35e0ff9..689d95e670 100755 --- a/test/rust_analyzer/rust_analyzer_test_runner.sh +++ b/test/rust_analyzer/rust_analyzer_test_runner.sh @@ -102,6 +102,10 @@ function rust_analyzer_test() { else RUST_LOG="${rust_log}" bazel run "@rules_rust//tools/rust_analyzer:gen_rust_project" fi + + echo "Generating auto-discovery.json..." + RUST_LOG="${rust_log}" bazel run "@rules_rust//tools/rust_analyzer:discover_bazel_rust_project" > auto-discovery.json + echo "Building..." bazel build //... echo "Testing..." diff --git a/tools/rust_analyzer/3rdparty/BUILD.bazel b/tools/rust_analyzer/3rdparty/BUILD.bazel index 9739042dbc..6759e52f17 100644 --- a/tools/rust_analyzer/3rdparty/BUILD.bazel +++ b/tools/rust_analyzer/3rdparty/BUILD.bazel @@ -9,6 +9,10 @@ crates_vendor( "anyhow": crate.spec( version = "1.0.71", ), + "camino": crate.spec( + features = ["serde1"], + version = "1.1.9", + ), "clap": crate.spec( features = [ "derive", diff --git a/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock b/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock index ab1c359e8b..feef41fd2b 100644 --- a/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock +++ b/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -72,6 +72,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + [[package]] name = "cc" version = "1.0.79" @@ -130,6 +139,7 @@ name = "direct-cargo-bazel-deps" version = "0.0.1" dependencies = [ "anyhow", + "camino", "clap", "env_logger", "itertools", diff --git a/tools/rust_analyzer/3rdparty/crates/BUILD.bazel b/tools/rust_analyzer/3rdparty/crates/BUILD.bazel index b33911c06e..a908c681ec 100644 --- a/tools/rust_analyzer/3rdparty/crates/BUILD.bazel +++ b/tools/rust_analyzer/3rdparty/crates/BUILD.bazel @@ -43,6 +43,18 @@ alias( tags = ["manual"], ) +alias( + name = "camino-1.1.9", + actual = "@rrra__camino-1.1.9//:camino", + tags = ["manual"], +) + +alias( + name = "camino", + actual = "@rrra__camino-1.1.9//:camino", + tags = ["manual"], +) + alias( name = "clap-4.3.11", actual = "@rrra__clap-4.3.11//:clap", diff --git a/tools/rust_analyzer/3rdparty/crates/BUILD.camino-1.1.9.bazel b/tools/rust_analyzer/3rdparty/crates/BUILD.camino-1.1.9.bazel new file mode 100644 index 0000000000..8f01a81c1f --- /dev/null +++ b/tools/rust_analyzer/3rdparty/crates/BUILD.camino-1.1.9.bazel @@ -0,0 +1,133 @@ +############################################################################### +# @generated +# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To +# regenerate this file, run the following: +# +# bazel run @@//tools/rust_analyzer/3rdparty:crates_vendor +############################################################################### + +load("@rules_rust//cargo:defs.bzl", "cargo_build_script") +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "camino", + srcs = glob( + include = ["**/*.rs"], + allow_empty = True, + ), + compile_data = glob( + include = ["**"], + allow_empty = True, + exclude = [ + "**/* *", + ".tmp_git_root/**/*", + "BUILD", + "BUILD.bazel", + "WORKSPACE", + "WORKSPACE.bazel", + ], + ), + crate_features = [ + "serde", + "serde1", + ], + crate_root = "src/lib.rs", + edition = "2018", + rustc_flags = [ + "--cap-lints=allow", + ], + tags = [ + "cargo-bazel", + "crate-name=camino", + "manual", + "noclippy", + "norustfmt", + ], + target_compatible_with = select({ + "@rules_rust//rust/platform:aarch64-apple-darwin": [], + "@rules_rust//rust/platform:aarch64-pc-windows-msvc": [], + "@rules_rust//rust/platform:aarch64-unknown-linux-gnu": [], + "@rules_rust//rust/platform:aarch64-unknown-nixos-gnu": [], + "@rules_rust//rust/platform:arm-unknown-linux-gnueabi": [], + "@rules_rust//rust/platform:armv7-linux-androideabi": [], + "@rules_rust//rust/platform:armv7-unknown-linux-gnueabi": [], + "@rules_rust//rust/platform:i686-apple-darwin": [], + "@rules_rust//rust/platform:i686-pc-windows-msvc": [], + "@rules_rust//rust/platform:i686-unknown-freebsd": [], + "@rules_rust//rust/platform:i686-unknown-linux-gnu": [], + "@rules_rust//rust/platform:powerpc-unknown-linux-gnu": [], + "@rules_rust//rust/platform:s390x-unknown-linux-gnu": [], + "@rules_rust//rust/platform:x86_64-apple-darwin": [], + "@rules_rust//rust/platform:x86_64-pc-windows-msvc": [], + "@rules_rust//rust/platform:x86_64-unknown-freebsd": [], + "@rules_rust//rust/platform:x86_64-unknown-linux-gnu": [], + "@rules_rust//rust/platform:x86_64-unknown-nixos-gnu": [], + "//conditions:default": ["@platforms//:incompatible"], + }), + version = "1.1.9", + deps = [ + "@rrra__camino-1.1.9//:build_script_build", + "@rrra__serde-1.0.171//:serde", + ], +) + +cargo_build_script( + name = "_bs", + srcs = glob( + include = ["**/*.rs"], + allow_empty = True, + ), + compile_data = glob( + include = ["**"], + allow_empty = True, + exclude = [ + "**/* *", + "**/*.rs", + ".tmp_git_root/**/*", + "BUILD", + "BUILD.bazel", + "WORKSPACE", + "WORKSPACE.bazel", + ], + ), + crate_features = [ + "serde", + "serde1", + ], + crate_name = "build_script_build", + crate_root = "build.rs", + data = glob( + include = ["**"], + allow_empty = True, + exclude = [ + "**/* *", + ".tmp_git_root/**/*", + "BUILD", + "BUILD.bazel", + "WORKSPACE", + "WORKSPACE.bazel", + ], + ), + edition = "2018", + pkg_name = "camino", + rustc_flags = [ + "--cap-lints=allow", + ], + tags = [ + "cargo-bazel", + "crate-name=camino", + "manual", + "noclippy", + "norustfmt", + ], + version = "1.1.9", + visibility = ["//visibility:private"], +) + +alias( + name = "build_script_build", + actual = ":_bs", + tags = ["manual"], +) diff --git a/tools/rust_analyzer/3rdparty/crates/defs.bzl b/tools/rust_analyzer/3rdparty/crates/defs.bzl index bc75a66472..081f00b1c8 100644 --- a/tools/rust_analyzer/3rdparty/crates/defs.bzl +++ b/tools/rust_analyzer/3rdparty/crates/defs.bzl @@ -296,6 +296,7 @@ _NORMAL_DEPENDENCIES = { "": { _COMMON_CONDITION: { "anyhow": Label("@rrra//:anyhow-1.0.71"), + "camino": Label("@rrra//:camino-1.1.9"), "clap": Label("@rrra//:clap-4.3.11"), "env_logger": Label("@rrra//:env_logger-0.10.0"), "itertools": Label("@rrra//:itertools-0.11.0"), @@ -490,6 +491,16 @@ def crate_repositories(): build_file = Label("//tools/rust_analyzer/3rdparty/crates:BUILD.bitflags-1.3.2.bazel"), ) + maybe( + http_archive, + name = "rrra__camino-1.1.9", + sha256 = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3", + type = "tar.gz", + urls = ["https://static.crates.io/crates/camino/1.1.9/download"], + strip_prefix = "camino-1.1.9", + build_file = Label("//tools/rust_analyzer/3rdparty/crates:BUILD.camino-1.1.9.bazel"), + ) + maybe( http_archive, name = "rrra__cc-1.0.79", @@ -992,6 +1003,7 @@ def crate_repositories(): return [ struct(repo = "rrra__anyhow-1.0.71", is_dev_dep = False), + struct(repo = "rrra__camino-1.1.9", is_dev_dep = False), struct(repo = "rrra__clap-4.3.11", is_dev_dep = False), struct(repo = "rrra__env_logger-0.10.0", is_dev_dep = False), struct(repo = "rrra__itertools-0.11.0", is_dev_dep = False), diff --git a/tools/rust_analyzer/BUILD.bazel b/tools/rust_analyzer/BUILD.bazel index e17691d6c8..018ca09240 100644 --- a/tools/rust_analyzer/BUILD.bazel +++ b/tools/rust_analyzer/BUILD.bazel @@ -2,9 +2,28 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load("//rust:defs.bzl", "rust_binary", "rust_clippy", "rust_library", "rust_test") load("//tools/private:tool_utils.bzl", "aspect_repository") +rust_binary( + name = "discover_bazel_rust_project", + srcs = ["bin/discover_rust_project.rs"], + edition = "2018", + rustc_env = { + "ASPECT_REPOSITORY": aspect_repository(), + }, + visibility = ["//visibility:public"], + deps = [ + ":gen_rust_project_lib", + "//tools/rust_analyzer/3rdparty/crates:anyhow", + "//tools/rust_analyzer/3rdparty/crates:camino", + "//tools/rust_analyzer/3rdparty/crates:clap", + "//tools/rust_analyzer/3rdparty/crates:env_logger", + "//tools/rust_analyzer/3rdparty/crates:log", + "//tools/rust_analyzer/3rdparty/crates:serde_json", + ], +) + rust_binary( name = "gen_rust_project", - srcs = ["main.rs"], + srcs = ["bin/gen_rust_project.rs"], edition = "2018", rustc_env = { "ASPECT_REPOSITORY": aspect_repository(), @@ -13,10 +32,11 @@ rust_binary( deps = [ ":gen_rust_project_lib", "//tools/rust_analyzer/3rdparty/crates:anyhow", + "//tools/rust_analyzer/3rdparty/crates:camino", "//tools/rust_analyzer/3rdparty/crates:clap", "//tools/rust_analyzer/3rdparty/crates:env_logger", "//tools/rust_analyzer/3rdparty/crates:log", - "//util/label", + "//tools/rust_analyzer/3rdparty/crates:serde_json", ], ) @@ -24,7 +44,7 @@ rust_library( name = "gen_rust_project_lib", srcs = glob( ["**/*.rs"], - exclude = ["main.rs"], + exclude = ["bin"], ), data = [ "//rust/private:rust_analyzer_detect_sysroot", @@ -33,6 +53,8 @@ rust_library( deps = [ "//rust/runfiles", "//tools/rust_analyzer/3rdparty/crates:anyhow", + "//tools/rust_analyzer/3rdparty/crates:camino", + "//tools/rust_analyzer/3rdparty/crates:clap", "//tools/rust_analyzer/3rdparty/crates:log", "//tools/rust_analyzer/3rdparty/crates:serde", "//tools/rust_analyzer/3rdparty/crates:serde_json", diff --git a/tools/rust_analyzer/aquery.rs b/tools/rust_analyzer/aquery.rs index 7bef2105be..f306aa5b75 100644 --- a/tools/rust_analyzer/aquery.rs +++ b/tools/rust_analyzer/aquery.rs @@ -1,11 +1,11 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; -use std::path::PathBuf; -use std::process::Command; use anyhow::Context; +use camino::{Utf8Path, Utf8PathBuf}; use serde::Deserialize; +use crate::{bazel_command, deserialize_file_content}; + #[derive(Debug, Deserialize)] struct AqueryOutput { artifacts: Vec, @@ -50,7 +50,16 @@ pub struct CrateSpec { pub cfg: Vec, pub env: BTreeMap, pub target: String, - pub crate_type: String, + pub crate_type: CrateType, + pub is_test: bool, + pub build: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CrateSpecBuild { + pub label: String, + pub build_file: String, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)] @@ -60,29 +69,38 @@ pub struct CrateSpecSource { pub include_dirs: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CrateType { + Bin, + Rlib, + Lib, + Dylib, + Cdylib, + Staticlib, + ProcMacro, +} + +#[allow(clippy::too_many_arguments)] pub fn get_crate_specs( - bazel: &Path, - config: &Option, - workspace: &Path, - execution_root: &Path, + bazel: &Utf8Path, + output_base: &Utf8Path, + workspace: &Utf8Path, + execution_root: &Utf8Path, + bazel_startup_options: &[String], + bazel_args: &[String], targets: &[String], rules_rust_name: &str, ) -> anyhow::Result> { + log::info!("running bazel aquery..."); log::debug!("Get crate specs with targets: {:?}", targets); let target_pattern = format!("deps({})", targets.join("+")); - let config_args = match config { - Some(config) => vec!["--config", config], - None => Vec::new(), - }; - let mut aquery_command = Command::new(bazel); + let mut aquery_command = bazel_command(bazel, Some(workspace), Some(output_base)); aquery_command - .current_dir(workspace) - .env_remove("BAZELISK_SKIP_WRAPPER") - .env_remove("BUILD_WORKING_DIRECTORY") - .env_remove("BUILD_WORKSPACE_DIRECTORY") + .args(bazel_startup_options) .arg("aquery") - .args(config_args) + .args(bazel_args) .arg("--include_aspects") .arg("--include_artifacts") .arg(format!( @@ -98,6 +116,8 @@ pub fn get_crate_specs( .output() .context("Failed to spawn aquery command")?; + log::info!("bazel aquery finished; parsing spec files..."); + let aquery_results = String::from_utf8(aquery_output.stdout) .context("Failed to decode aquery results as utf-8.")?; @@ -107,22 +127,16 @@ pub fn get_crate_specs( let crate_specs = crate_spec_files .into_iter() - .map(|file| { - let content = std::fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - log::trace!("{}\n{}", file.display(), content); - serde_json::from_str(&content) - .with_context(|| format!("Failed to deserialize file: {}", file.display())) - }) + .map(|file| deserialize_file_content(&file, output_base, workspace, execution_root)) .collect::>>()?; consolidate_crate_specs(crate_specs) } fn parse_aquery_output_files( - execution_root: &Path, + execution_root: &Utf8Path, aquery_stdout: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { let out: AqueryOutput = serde_json::from_str(aquery_stdout).map_err(|_| { // Parsing to `AqueryOutput` failed, try parsing into a `serde_json::Value`: match serde_json::from_str::(aquery_stdout) { @@ -147,7 +161,7 @@ fn parse_aquery_output_files( .map(|pf| (pf.id, pf)) .collect::>(); - let mut output_files: Vec = Vec::new(); + let mut output_files: Vec = Vec::new(); for action in out.actions { for output_id in action.output_ids { let artifact = artifacts @@ -169,15 +183,15 @@ fn parse_aquery_output_files( fn path_from_fragments( id: u32, fragments: &BTreeMap, -) -> anyhow::Result { +) -> anyhow::Result { let path_fragment = fragments .get(&id) .expect("internal consistency error in bazel output"); let buf = match path_fragment.parent_id { Some(parent_id) => path_from_fragments(parent_id, fragments)? - .join(PathBuf::from(&path_fragment.label.clone())), - None => PathBuf::from(&path_fragment.label.clone()), + .join(Utf8PathBuf::from(&path_fragment.label.clone())), + None => Utf8PathBuf::from(&path_fragment.label.clone()), }; Ok(buf) @@ -191,6 +205,7 @@ fn consolidate_crate_specs(crate_specs: Vec) -> anyhow::Result) -> anyhow::Result. + +use std::{ + env, + io::{self, Write}, +}; + +use anyhow::Context; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::Parser; +use env_logger::{fmt::Formatter, Target, WriteStyle}; +use gen_rust_project_lib::{ + bazel_info, generate_rust_project, DiscoverProject, RustAnalyzerArg, BUILD_FILE_NAMES, + WORKSPACE_ROOT_FILE_NAMES, +}; +use log::{LevelFilter, Record}; + +/// Looks within the current directory for a file that marks a bazel workspace. +/// +/// # Errors +/// +/// Returns an error if no file from [`WORKSPACE_ROOT_FILE_NAMES`] is found. +fn find_workspace_root_file(workspace: &Utf8Path) -> anyhow::Result { + BUILD_FILE_NAMES + .iter() + .chain(WORKSPACE_ROOT_FILE_NAMES) + .map(|file| workspace.join(file)) + .find(|p| p.exists()) + .with_context(|| format!("no root file found for bazel workspace {workspace}")) +} + +fn project_discovery() -> anyhow::Result> { + let Config { + workspace, + execution_root, + output_base, + bazel, + bazel_startup_options, + bazel_args, + rust_analyzer_argument, + } = Config::parse()?; + + log::info!("got rust-analyzer argument: {rust_analyzer_argument:?}"); + + let ra_arg = match rust_analyzer_argument { + Some(ra_arg) => ra_arg, + None => RustAnalyzerArg::Buildfile(find_workspace_root_file(&workspace)?), + }; + + let rules_rust_name = env!("ASPECT_REPOSITORY"); + + log::info!("resolved rust-analyzer argument: {ra_arg:?}"); + + let (buildfile, targets) = ra_arg.into_target_details(&workspace)?; + + log::debug!("got buildfile: {buildfile}"); + log::debug!("got targets: {targets}"); + + // Use the generated files to print the rust-project.json. + let project = generate_rust_project( + &bazel, + &output_base, + &workspace, + &execution_root, + &bazel_startup_options, + &bazel_args, + rules_rust_name, + &[targets], + )?; + + Ok(DiscoverProject::Finished { buildfile, project }) +} + +#[allow(clippy::writeln_empty_string)] +fn write_discovery(mut writer: W, discovery: DiscoverProject) -> std::io::Result<()> +where + W: Write, +{ + serde_json::to_writer(&mut writer, &discovery)?; + // `rust-analyzer` reads messages line by line, so we must add a newline after each + writeln!(writer, "") +} + +fn main() -> anyhow::Result<()> { + let log_format_fn = |fmt: &mut Formatter, rec: &Record| { + let message = rec.args(); + let discovery = DiscoverProject::Progress { message }; + write_discovery(fmt, discovery) + }; + + // Treat logs as progress messages. + env_logger::Builder::from_default_env() + // Never write color/styling info + .write_style(WriteStyle::Never) + // Format logs as progress messages + .format(log_format_fn) + // `rust-analyzer` reads the stdout + .filter_level(LevelFilter::Debug) + .target(Target::Stdout) + .init(); + + let discovery = match project_discovery() { + Ok(discovery) => discovery, + Err(error) => DiscoverProject::Error { + error: error.to_string(), + source: error.source().as_ref().map(ToString::to_string), + }, + }; + + write_discovery(io::stdout(), discovery)?; + Ok(()) +} + +#[derive(Debug)] +pub struct Config { + /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`. + workspace: Utf8PathBuf, + + /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`. + execution_root: Utf8PathBuf, + + /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`. + output_base: Utf8PathBuf, + + /// The path to a Bazel binary. + bazel: Utf8PathBuf, + + /// Startup options to pass to `bazel` invocations. + /// See the [Command-Line Reference]() + /// for more details. + bazel_startup_options: Vec, + + /// Arguments to pass to `bazel` invocations. + /// See the [Command-Line Reference]() + /// for more details. + bazel_args: Vec, + + /// The argument that `rust-analyzer` can pass to the binary. + rust_analyzer_argument: Option, +} + +impl Config { + // Parse the configuration flags and supplement with bazel info as needed. + pub fn parse() -> anyhow::Result { + let ConfigParser { + workspace, + bazel, + bazel_startup_options, + bazel_args, + rust_analyzer_argument, + } = ConfigParser::parse(); + + // We need some info from `bazel info`. Fetch it now. + let mut info_map = bazel_info( + &bazel, + workspace.as_deref(), + None, + &bazel_startup_options, + &bazel_args, + )?; + + let config = Config { + workspace: info_map + .remove("workspace") + .expect("'workspace' must exist in bazel info") + .into(), + execution_root: info_map + .remove("execution_root") + .expect("'execution_root' must exist in bazel info") + .into(), + output_base: info_map + .remove("output_base") + .expect("'output_base' must exist in bazel info") + .into(), + bazel, + bazel_startup_options, + bazel_args, + rust_analyzer_argument, + }; + + Ok(config) + } +} + +#[derive(Debug, Parser)] +struct ConfigParser { + /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`. + #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")] + workspace: Option, + + /// The path to a Bazel binary. + #[clap(long, default_value = "bazel")] + bazel: Utf8PathBuf, + + /// Startup options to pass to `bazel` invocations. + /// See the [Command-Line Reference]() + /// for more details. + #[clap(long = "bazel_startup_option")] + bazel_startup_options: Vec, + + /// Arguments to pass to `bazel` invocations. + /// See the [Command-Line Reference]() + /// for more details. + #[clap(long = "bazel_arg")] + bazel_args: Vec, + + /// The argument that `rust-analyzer` can pass to the binary. + rust_analyzer_argument: Option, +} diff --git a/tools/rust_analyzer/bin/gen_rust_project.rs b/tools/rust_analyzer/bin/gen_rust_project.rs new file mode 100644 index 0000000000..e7892e80b7 --- /dev/null +++ b/tools/rust_analyzer/bin/gen_rust_project.rs @@ -0,0 +1,179 @@ +use std::{ + env, + fs::OpenOptions, + io::{BufWriter, ErrorKind}, +}; + +use anyhow::{bail, Context}; +use camino::Utf8PathBuf; +use clap::Parser; +use gen_rust_project_lib::{bazel_info, generate_rust_project}; + +fn write_rust_project() -> anyhow::Result<()> { + let Config { + workspace, + execution_root, + output_base, + bazel, + bazel_args, + targets, + } = Config::parse()?; + + let rules_rust_name = env!("ASPECT_REPOSITORY"); + + let rust_project = generate_rust_project( + &bazel, + &output_base, + &workspace, + &execution_root, + &[], + &bazel_args, + rules_rust_name, + &targets, + )?; + + let rust_project_path = &workspace.join("rust-project.json"); + + // Try to remove the existing rust-project.json. It's OK if the file doesn't exist. + match std::fs::remove_file(rust_project_path) { + Ok(_) => {} + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => bail!("Unexpected error removing old rust-project.json: {}", err), + } + + // Write the new rust-project.json file. + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(rust_project_path) + .with_context(|| format!("could not open: {rust_project_path}")) + .map(BufWriter::new)?; + + serde_json::to_writer(file, &rust_project)?; + Ok(()) +} + +// TODO(david): This shells out to an expected rule in the workspace root //:rust_analyzer that the user must define. +// It would be more convenient if it could automatically discover all the rust code in the workspace if this target +// does not exist. +fn main() -> anyhow::Result<()> { + env_logger::init(); + + // Write rust-project.json. + write_rust_project()?; + Ok(()) +} + +#[derive(Debug)] +pub struct Config { + /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`. + workspace: Utf8PathBuf, + + /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`. + execution_root: Utf8PathBuf, + + /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`. + output_base: Utf8PathBuf, + + /// The path to a Bazel binary. + bazel: Utf8PathBuf, + + /// Arguments to pass to `bazel` invocations. + /// See the [Command-Line Reference]() + /// for more details. + bazel_args: Vec, + + /// Space separated list of target patterns that comes after all other args. + targets: Vec, +} + +impl Config { + // Parse the configuration flags and supplement with bazel info as needed. + pub fn parse() -> anyhow::Result { + let ConfigParser { + workspace, + execution_root, + output_base, + bazel, + config, + targets, + } = ConfigParser::parse(); + + let bazel_args = config + .into_iter() + .map(|s| format!("--config={s}")) + .collect(); + + // Implemented this way instead of a classic `if let` to satisfy the + // borrow checker. + // See: + #[allow(clippy::unnecessary_unwrap)] + if workspace.is_some() && execution_root.is_some() && output_base.is_some() { + return Ok(Config { + workspace: workspace.unwrap(), + execution_root: execution_root.unwrap(), + output_base: output_base.unwrap(), + bazel, + bazel_args, + targets, + }); + } + + // We need some info from `bazel info`. Fetch it now. + let mut info_map = bazel_info( + &bazel, + workspace.as_deref(), + output_base.as_deref(), + &[], + &[], + )?; + + let config = Config { + workspace: info_map + .remove("workspace") + .expect("'workspace' must exist in bazel info") + .into(), + execution_root: info_map + .remove("execution_root") + .expect("'execution_root' must exist in bazel info") + .into(), + output_base: info_map + .remove("output_base") + .expect("'output_base' must exist in bazel info") + .into(), + bazel, + bazel_args, + targets, + }; + + Ok(config) + } +} + +#[derive(Debug, Parser)] +struct ConfigParser { + /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`. + #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")] + workspace: Option, + + /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`. + #[clap(long)] + execution_root: Option, + + /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`. + #[clap(long, env = "OUTPUT_BASE")] + output_base: Option, + + /// The path to a Bazel binary. + #[clap(long, default_value = "bazel")] + bazel: Utf8PathBuf, + + /// A config to pass to Bazel invocations with `--config=`. + #[clap(long)] + config: Option, + + /// Space separated list of target patterns that comes after all other args. + #[clap(default_value = "@//...")] + targets: Vec, +} diff --git a/tools/rust_analyzer/lib.rs b/tools/rust_analyzer/lib.rs index 6842216474..4ea641e6a0 100644 --- a/tools/rust_analyzer/lib.rs +++ b/tools/rust_analyzer/lib.rs @@ -1,94 +1,200 @@ -use std::collections::HashMap; -use std::path::Path; -use std::process::Command; +mod aquery; +mod rust_project; -use anyhow::anyhow; +use std::{collections::BTreeMap, convert::TryInto, fs, process::Command}; + +use anyhow::{bail, Context}; +use camino::{Utf8Path, Utf8PathBuf}; use runfiles::Runfiles; +use rust_project::RustProject; +pub use rust_project::{DiscoverProject, RustAnalyzerArg}; +use serde::{de::DeserializeOwned, Deserialize}; -mod aquery; -mod rust_project; +pub const WORKSPACE_ROOT_FILE_NAMES: &[&str] = + &["MODULE.bazel", "REPO.bazel", "WORKSPACE.bazel", "WORKSPACE"]; + +pub const BUILD_FILE_NAMES: &[&str] = &["BUILD.bazel", "BUILD"]; + +#[allow(clippy::too_many_arguments)] +pub fn generate_rust_project( + bazel: &Utf8Path, + output_base: &Utf8Path, + workspace: &Utf8Path, + execution_root: &Utf8Path, + bazel_startup_options: &[String], + bazel_args: &[String], + rules_rust_name: &str, + targets: &[String], +) -> anyhow::Result { + generate_crate_info( + bazel, + output_base, + workspace, + bazel_startup_options, + bazel_args, + rules_rust_name, + targets, + )?; + + let crate_specs = aquery::get_crate_specs( + bazel, + output_base, + workspace, + execution_root, + bazel_startup_options, + bazel_args, + targets, + rules_rust_name, + )?; + + let path: Utf8PathBuf = runfiles::rlocation!( + Runfiles::create()?, + "rules_rust/rust/private/rust_analyzer_detect_sysroot.rust_analyzer_toolchain.json" + ) + .context("toolchain runfile not found")? + .try_into()?; + + let toolchain_info = deserialize_file_content(&path, output_base, workspace, execution_root)?; + + rust_project::assemble_rust_project(bazel, workspace, toolchain_info, &crate_specs) +} + +/// Executes `bazel info` to get a map of context information. +pub fn bazel_info( + bazel: &Utf8Path, + workspace: Option<&Utf8Path>, + output_base: Option<&Utf8Path>, + bazel_startup_options: &[String], + bazel_args: &[String], +) -> anyhow::Result> { + let output = bazel_command(bazel, workspace, output_base) + .args(bazel_startup_options) + .arg("info") + .args(bazel_args) + .output()?; + + if !output.status.success() { + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("bazel info failed: ({status:?})\n{stderr}"); + } + + // Extract and parse the output. + let info_map = String::from_utf8(output.stdout)? + .trim() + .split('\n') + .filter_map(|line| line.split_once(':')) + .map(|(k, v)| (k.to_owned(), v.trim().to_owned())) + .collect(); + + Ok(info_map) +} -pub fn generate_crate_info( - bazel: impl AsRef, - config: &Option, - workspace: impl AsRef, - rules_rust: impl AsRef, +fn generate_crate_info( + bazel: &Utf8Path, + output_base: &Utf8Path, + workspace: &Utf8Path, + bazel_startup_options: &[String], + bazel_args: &[String], + rules_rust: &str, targets: &[String], ) -> anyhow::Result<()> { + log::info!("running bazel build..."); log::debug!("Building rust_analyzer_crate_spec files for {:?}", targets); - let config_args = match config { - Some(config) => vec!["--config", config], - None => Vec::new(), - }; - let output = Command::new(bazel.as_ref()) - .current_dir(workspace.as_ref()) - .env_remove("BAZELISK_SKIP_WRAPPER") - .env_remove("BUILD_WORKING_DIRECTORY") - .env_remove("BUILD_WORKSPACE_DIRECTORY") + let output = bazel_command(bazel, Some(workspace), Some(output_base)) + .args(bazel_startup_options) .arg("build") - .args(config_args) + .args(bazel_args) .arg("--norun_validations") .arg("--remote_download_all") .arg(format!( - "--aspects={}//rust:defs.bzl%rust_analyzer_aspect", - rules_rust.as_ref() + "--aspects={rules_rust}//rust:defs.bzl%rust_analyzer_aspect" )) - .arg("--output_groups=rust_analyzer_crate_spec,rust_generated_srcs") + .arg("--output_groups=rust_analyzer_crate_spec,rust_generated_srcs,rust_analyzer_proc_macro_dylib,rust_analyzer_src") .args(targets) .output()?; if !output.status.success() { - return Err(anyhow!( - "bazel build failed:({})\n{}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("bazel build failed: ({status})\n{stderr}"); } + log::info!("bazel build finished"); + Ok(()) } -#[allow(clippy::too_many_arguments)] -pub fn write_rust_project( - bazel: impl AsRef, - config: &Option, - workspace: impl AsRef, - rules_rust_name: &impl AsRef, - targets: &[String], - execution_root: impl AsRef, - output_base: impl AsRef, - rust_project_path: impl AsRef, -) -> anyhow::Result<()> { - let crate_specs = aquery::get_crate_specs( - bazel.as_ref(), - config, - workspace.as_ref(), - execution_root.as_ref(), - targets, - rules_rust_name.as_ref(), - )?; +fn bazel_command( + bazel: &Utf8Path, + workspace: Option<&Utf8Path>, + output_base: Option<&Utf8Path>, +) -> Command { + let mut cmd = Command::new(bazel); - let path = runfiles::rlocation!( - Runfiles::create()?, - "rules_rust/rust/private/rust_analyzer_detect_sysroot.rust_analyzer_toolchain.json" - ) - .unwrap(); - let toolchain_info: HashMap = - serde_json::from_str(&std::fs::read_to_string(path)?)?; + cmd + // Switch to the workspace directory if one was provided. + .current_dir(workspace.unwrap_or(Utf8Path::new("."))) + .env_remove("BAZELISK_SKIP_WRAPPER") + .env_remove("BUILD_WORKING_DIRECTORY") + .env_remove("BUILD_WORKSPACE_DIRECTORY") + // Set the output_base if one was provided. + .args(output_base.map(|s| format!("--output_base={s}"))); - let sysroot_src = &toolchain_info["sysroot_src"]; - let sysroot = &toolchain_info["sysroot"]; + cmd +} - let rust_project = rust_project::generate_rust_project(sysroot, sysroot_src, &crate_specs)?; +fn deserialize_file_content( + path: &Utf8Path, + output_base: &Utf8Path, + workspace: &Utf8Path, + execution_root: &Utf8Path, +) -> anyhow::Result +where + T: DeserializeOwned, +{ + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read file: {path}"))? + .replace("__WORKSPACE__", workspace.as_str()) + .replace("${pwd}", execution_root.as_str()) + .replace("__EXEC_ROOT__", execution_root.as_str()) + .replace("__OUTPUT_BASE__", output_base.as_str()); - rust_project::write_rust_project( - rust_project_path.as_ref(), - workspace.as_ref(), - execution_root.as_ref(), - output_base.as_ref(), - &rust_project, - )?; + log::trace!("{}\n{}", path, content); - Ok(()) + serde_json::from_str(&content).with_context(|| format!("failed to deserialize file: {path}")) +} + +/// `rust-analyzer` associates workspaces with buildfiles. Therefore, when it passes in a +/// source file path, we use this function to identify the buildfile the file belongs to. +fn source_file_to_buildfile(file: &Utf8Path) -> anyhow::Result { + // Skip the first element as it's always the full file path. + file.ancestors() + .skip(1) + .flat_map(|dir| BUILD_FILE_NAMES.iter().map(move |build| dir.join(build))) + .find(|p| p.exists()) + .with_context(|| format!("no buildfile found for {file}")) +} + +fn buildfile_to_targets(workspace: &Utf8Path, buildfile: &Utf8Path) -> anyhow::Result { + log::info!("getting targets for buildfile: {buildfile}"); + + let parent_dir = buildfile + .strip_prefix(workspace) + .with_context(|| format!("{buildfile} not part of workspace"))? + .parent(); + + let targets = match parent_dir { + Some(p) if !p.as_str().is_empty() => format!("//{p}:all"), + _ => "//...".to_string(), + }; + + Ok(targets) +} + +#[derive(Debug, Deserialize)] +struct ToolchainInfo { + sysroot: Utf8PathBuf, + sysroot_src: Utf8PathBuf, } diff --git a/tools/rust_analyzer/main.rs b/tools/rust_analyzer/main.rs deleted file mode 100644 index f1dc0143fb..0000000000 --- a/tools/rust_analyzer/main.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::collections::HashMap; -use std::env; -use std::path::PathBuf; -use std::process::Command; - -use anyhow::anyhow; -use clap::Parser; -use gen_rust_project_lib::generate_crate_info; -use gen_rust_project_lib::write_rust_project; - -// TODO(david): This shells out to an expected rule in the workspace root //:rust_analyzer that the user must define. -// It would be more convenient if it could automatically discover all the rust code in the workspace if this target -// does not exist. -fn main() -> anyhow::Result<()> { - env_logger::init(); - - let config = parse_config()?; - - let workspace_root = config - .workspace - .as_ref() - .expect("failed to find workspace root, set with --workspace"); - - let execution_root = config - .execution_root - .as_ref() - .expect("failed to find execution root, is --execution-root set correctly?"); - - let output_base = config - .output_base - .as_ref() - .expect("failed to find output base, is -output-base set correctly?"); - - let rules_rust_name = env!("ASPECT_REPOSITORY"); - - // Generate the crate specs. - generate_crate_info( - &config.bazel, - &config.config, - workspace_root, - rules_rust_name, - &config.targets, - )?; - - // Use the generated files to write rust-project.json. - write_rust_project( - &config.bazel, - &config.config, - workspace_root, - &rules_rust_name, - &config.targets, - execution_root, - output_base, - workspace_root.join("rust-project.json"), - )?; - - Ok(()) -} - -// Parse the configuration flags and supplement with bazel info as needed. -fn parse_config() -> anyhow::Result { - let mut config = Config::parse(); - - if config.workspace.is_some() && config.execution_root.is_some() { - return Ok(config); - } - - // We need some info from `bazel info`. Fetch it now. - let mut bazel_info_command = Command::new(&config.bazel); - bazel_info_command - .env_remove("BAZELISK_SKIP_WRAPPER") - .env_remove("BUILD_WORKING_DIRECTORY") - .env_remove("BUILD_WORKSPACE_DIRECTORY") - .arg("info"); - if let Some(workspace) = &config.workspace { - bazel_info_command.current_dir(workspace); - } - - // Execute bazel info. - let output = bazel_info_command.output()?; - if !output.status.success() { - return Err(anyhow!( - "Failed to run `bazel info` ({:?}): {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - - // Extract the output. - let output = String::from_utf8_lossy(output.stdout.as_slice()); - let bazel_info = output - .trim() - .split('\n') - .map(|line| line.split_at(line.find(':').expect("missing `:` in bazel info output"))) - .map(|(k, v)| (k, (v[1..]).trim())) - .collect::>(); - - if config.workspace.is_none() { - config.workspace = bazel_info.get("workspace").map(Into::into); - } - if config.execution_root.is_none() { - config.execution_root = bazel_info.get("execution_root").map(Into::into); - } - if config.output_base.is_none() { - config.output_base = bazel_info.get("output_base").map(Into::into); - } - - Ok(config) -} - -#[derive(Debug, Parser)] -struct Config { - /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`. - #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")] - workspace: Option, - - /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`. - #[clap(long)] - execution_root: Option, - - /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`. - #[clap(long, env = "OUTPUT_BASE")] - output_base: Option, - - /// A config to pass to Bazel invocations with `--config=`. - #[clap(long)] - config: Option, - - /// The path to a Bazel binary - #[clap(long, default_value = "bazel")] - bazel: PathBuf, - - /// Space separated list of target patterns that comes after all other args. - #[clap(default_value = "@//...")] - targets: Vec, -} diff --git a/tools/rust_analyzer/rust_project.rs b/tools/rust_analyzer/rust_project.rs index f694267379..749347ea33 100644 --- a/tools/rust_analyzer/rust_project.rs +++ b/tools/rust_analyzer/rust_project.rs @@ -1,14 +1,74 @@ //! Library for generating rust_project.json files from a `Vec` //! See official documentation of file format at https://rust-analyzer.github.io/manual.html -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::io::ErrorKind; -use std::path::Path; +use core::fmt; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + str::FromStr, +}; + +use anyhow::{anyhow, Context}; +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; + +use crate::{ + aquery::{CrateSpec, CrateType}, + buildfile_to_targets, source_file_to_buildfile, ToolchainInfo, +}; + +/// The argument that `rust-analyzer` can pass to the workspace discovery command. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RustAnalyzerArg { + Path(Utf8PathBuf), + Buildfile(Utf8PathBuf), +} + +impl RustAnalyzerArg { + /// Consumes itself to return a build file and the targets to build. + pub fn into_target_details( + self, + workspace: &Utf8Path, + ) -> anyhow::Result<(Utf8PathBuf, String)> { + match self { + Self::Path(file) => { + let buildfile = source_file_to_buildfile(&file)?; + buildfile_to_targets(workspace, &buildfile).map(|t| (buildfile, t)) + } + Self::Buildfile(buildfile) => { + buildfile_to_targets(workspace, &buildfile).map(|t| (buildfile, t)) + } + } + } +} -use anyhow::anyhow; -use serde::Serialize; +impl FromStr for RustAnalyzerArg { + type Err = anyhow::Error; -use crate::aquery::CrateSpec; + fn from_str(s: &str) -> Result { + serde_json::from_str(s).context("rust analyzer argument error") + } +} + +/// The format that `rust_analyzer` expects as a response when automatically invoked. +/// See [rust-analyzer documentation][rd] for a thorough description of this interface. +/// [rd]: . +#[derive(Debug, Serialize)] +#[serde(tag = "kind")] +#[serde(rename_all = "snake_case")] +pub enum DiscoverProject<'a> { + Finished { + buildfile: Utf8PathBuf, + project: RustProject, + }, + Error { + error: String, + source: Option, + }, + Progress { + message: &'a fmt::Arguments<'a>, + }, +} /// A `rust-project.json` workspace representation. See /// [rust-analyzer documentation][rd] for a thorough description of this interface. @@ -16,24 +76,27 @@ use crate::aquery::CrateSpec; #[derive(Debug, Serialize)] pub struct RustProject { /// The path to a Rust sysroot. - sysroot: Option, + sysroot: Utf8PathBuf, /// Path to the directory with *source code* of /// sysroot crates. - sysroot_src: Option, + sysroot_src: Utf8PathBuf, /// The set of crates comprising the current /// project. Must include all transitive /// dependencies as well as sysroot crate (libstd, /// libcore and such). crates: Vec, + + /// The set of runnables, such as tests or benchmarks, + /// that can be found in the crate. + runnables: Vec, } /// A `rust-project.json` crate representation. See /// [rust-analyzer documentation][rd] for a thorough description of this interface. /// [rd]: https://rust-analyzer.github.io/manual.html#non-cargo-based-projects #[derive(Debug, Serialize)] -#[serde(default)] pub struct Crate { /// A name used in the package's project declaration #[serde(skip_serializing_if = "Option::is_none")] @@ -74,6 +137,10 @@ pub struct Crate { /// For proc-macro crates, path to compiled proc-macro (.so file). #[serde(skip_serializing_if = "Option::is_none")] proc_macro_dylib_path: Option, + + /// Build information for the crate + #[serde(skip_serializing_if = "Option::is_none")] + build: Option, } #[derive(Debug, Default, Serialize)] @@ -83,7 +150,6 @@ pub struct Source { } impl Source { - /// Returns true if no include information has been added. fn is_empty(&self) -> bool { self.include_dirs.is_empty() && self.exclude_dirs.is_empty() } @@ -99,18 +165,131 @@ pub struct Dependency { name: String, } -pub fn generate_rust_project( - sysroot: &str, - sysroot_src: &str, - crates: &BTreeSet, +#[derive(Debug, Serialize)] +pub struct Build { + /// The name associated with this crate. + /// + /// This is determined by the build system that produced + /// the `rust-project.json` in question. For instance, if bazel were used, + /// the label might be something like `//ide/rust/rust-analyzer:rust-analyzer`. + /// + /// Do not attempt to parse the contents of this string; it is a build system-specific + /// identifier similar to [`Crate::display_name`]. + label: String, + /// Path corresponding to the build system-specific file defining the crate. + /// + /// It is roughly analogous to [`ManifestPath`], but it should *not* be used with + /// [`crate::ProjectManifest::from_manifest_file`], as the build file may not be + /// be in the `rust-project.json`. + build_file: Utf8PathBuf, + /// The kind of target. + /// + /// Examples (non-exhaustively) include [`TargetKind::Bin`], [`TargetKind::Lib`], + /// and [`TargetKind::Test`]. This information is used to determine what sort + /// of runnable codelens to provide, if any. + target_kind: TargetKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TargetKind { + Bin, + /// Any kind of Cargo lib crate-type (dylib, rlib, proc-macro, ...). + Lib, + Test, +} + +/// A template-like structure for describing runnables. +/// +/// These are used for running and debugging binaries and tests without encoding +/// build system-specific knowledge into rust-analyzer. +/// +/// # Example +/// +/// Below is an example of a test runnable. `{label}` and `{test_id}` +/// are explained in [`Runnable::args`]'s documentation. +/// +/// ```json +/// { +/// "program": "bazel", +/// "args": [ +/// "test", +/// "{label}", +/// "--test_arg", +/// "{test_id}", +/// ], +/// "cwd": "/home/user/repo-root/", +/// "kind": "testOne" +/// } +/// ``` +#[derive(Debug, Serialize)] +pub struct Runnable { + /// The program invoked by the runnable. + /// + /// For example, this might be `cargo`, `bazel`, etc. + program: String, + /// The arguments passed to [`Runnable::program`]. + /// + /// The args can contain two template strings: `{label}` and `{test_id}`. + /// rust-analyzer will find and replace `{label}` with [`Build::label`] and + /// `{test_id}` with the test name. + args: Vec, + /// The current working directory of the runnable. + cwd: Utf8PathBuf, + kind: RunnableKind, +} + +/// The kind of runnable. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RunnableKind { + Check, + + /// Can run a binary. + Run, + + /// Run a single test. + TestOne, +} + +pub fn assemble_rust_project( + bazel: &Utf8Path, + workspace: &Utf8Path, + toolchain_info: ToolchainInfo, + crate_specs: &BTreeSet, ) -> anyhow::Result { let mut project = RustProject { - sysroot: Some(sysroot.into()), - sysroot_src: Some(sysroot_src.into()), + sysroot: toolchain_info.sysroot, + sysroot_src: toolchain_info.sysroot_src, crates: Vec::new(), + runnables: vec![ + Runnable { + program: bazel.to_string(), + args: vec!["build".to_owned(), "{label}".to_owned()], + cwd: workspace.to_owned(), + kind: RunnableKind::Check, + }, + Runnable { + program: bazel.to_string(), + args: vec![ + "test".to_owned(), + "{label}".to_owned(), + "--test_output".to_owned(), + "streamed".to_owned(), + "--test_arg".to_owned(), + "--nocapture".to_owned(), + "--test_arg".to_owned(), + "--exact".to_owned(), + "--test_arg".to_owned(), + "{test_id}".to_owned(), + ], + cwd: workspace.to_owned(), + kind: RunnableKind::TestOne, + }, + ], }; - let mut unmerged_crates: Vec<&CrateSpec> = crates.iter().collect(); + let mut unmerged_crates: Vec<&CrateSpec> = crate_specs.iter().collect(); let mut skipped_crates: Vec<&CrateSpec> = Vec::new(); let mut merged_crates_index: HashMap = HashMap::new(); @@ -133,6 +312,29 @@ pub fn generate_rust_project( } else { log::trace!("Merging crate {}", &c.crate_id); merged_crates_index.insert(c.crate_id.clone(), project.crates.len()); + + let target_kind = match c.crate_type { + CrateType::Bin if c.is_test => TargetKind::Test, + CrateType::Bin => TargetKind::Bin, + CrateType::Rlib + | CrateType::Lib + | CrateType::Dylib + | CrateType::Cdylib + | CrateType::Staticlib + | CrateType::ProcMacro => TargetKind::Lib, + }; + + if let Some(build) = &c.build { + if target_kind == TargetKind::Bin { + project.runnables.push(Runnable { + program: bazel.to_string(), + args: vec!["run".to_string(), build.label.to_owned()], + cwd: workspace.to_owned(), + kind: RunnableKind::Run, + }); + } + } + project.crates.push(Crate { display_name: Some(c.display_name.clone()), root_module: c.root_module.clone(), @@ -170,6 +372,11 @@ pub fn generate_rust_project( env: Some(c.env.clone()), is_proc_macro: c.proc_macro_dylib_path.is_some(), proc_macro_dylib_path: c.proc_macro_dylib_path.clone(), + build: c.build.as_ref().map(|b| Build { + label: b.label.clone(), + build_file: b.build_file.clone().into(), + target_kind, + }), }); } } @@ -241,51 +448,6 @@ fn detect_cycle<'a>( None } -pub fn write_rust_project( - rust_project_path: &Path, - workspace: &Path, - execution_root: &Path, - output_base: &Path, - rust_project: &RustProject, -) -> anyhow::Result<()> { - let workspace = workspace - .to_str() - .ok_or_else(|| anyhow!("workspace is not valid UTF-8"))?; - - let execution_root = execution_root - .to_str() - .ok_or_else(|| anyhow!("execution_root is not valid UTF-8"))?; - - let output_base = output_base - .to_str() - .ok_or_else(|| anyhow!("output_base is not valid UTF-8"))?; - - // Try to remove the existing rust-project.json. It's OK if the file doesn't exist. - match std::fs::remove_file(rust_project_path) { - Ok(_) => {} - Err(err) if err.kind() == ErrorKind::NotFound => {} - Err(err) => { - return Err(anyhow!( - "Unexpected error removing old rust-project.json: {}", - err - )) - } - } - - // Render the `rust-project.json` file and replace the exec root - // placeholders with the path to the local exec root. - let rust_project_content = serde_json::to_string_pretty(rust_project)? - .replace("${pwd}", execution_root) - .replace("__EXEC_ROOT__", execution_root) - .replace("__OUTPUT_BASE__", output_base) - .replace("__WORKSPACE__", workspace); - - // Write the new rust-project.json file. - std::fs::write(rust_project_path, rust_project_content)?; - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -293,9 +455,13 @@ mod tests { /// A simple example with a single crate and no dependencies. #[test] fn generate_rust_project_single() { - let project = generate_rust_project( - "sysroot", - "sysroot_src", + let project = assemble_rust_project( + Utf8Path::new("bazel"), + Utf8Path::new("workspace"), + ToolchainInfo { + sysroot: "sysroot".to_owned().into(), + sysroot_src: "sysroot_src".to_owned().into(), + }, &BTreeSet::from([CrateSpec { aliases: BTreeMap::new(), crate_id: "ID-example".into(), @@ -309,7 +475,9 @@ mod tests { cfg: vec!["test".into(), "debug_assertions".into()], env: BTreeMap::new(), target: "x86_64-unknown-linux-gnu".into(), - crate_type: "rlib".into(), + crate_type: CrateType::Rlib, + is_test: false, + build: None, }]), ) .expect("expect success"); @@ -324,9 +492,13 @@ mod tests { /// An example with a one crate having two dependencies. #[test] fn generate_rust_project_with_deps() { - let project = generate_rust_project( - "sysroot", - "sysroot_src", + let project = assemble_rust_project( + Utf8Path::new("bazel"), + Utf8Path::new("workspace"), + ToolchainInfo { + sysroot: "sysroot".to_owned().into(), + sysroot_src: "sysroot_src".to_owned().into(), + }, &BTreeSet::from([ CrateSpec { aliases: BTreeMap::new(), @@ -341,7 +513,9 @@ mod tests { cfg: vec!["test".into(), "debug_assertions".into()], env: BTreeMap::new(), target: "x86_64-unknown-linux-gnu".into(), - crate_type: "rlib".into(), + crate_type: CrateType::Rlib, + is_test: false, + build: None, }, CrateSpec { aliases: BTreeMap::new(), @@ -356,7 +530,9 @@ mod tests { cfg: vec!["test".into(), "debug_assertions".into()], env: BTreeMap::new(), target: "x86_64-unknown-linux-gnu".into(), - crate_type: "rlib".into(), + crate_type: CrateType::Rlib, + is_test: false, + build: None, }, CrateSpec { aliases: BTreeMap::new(), @@ -371,7 +547,9 @@ mod tests { cfg: vec!["test".into(), "debug_assertions".into()], env: BTreeMap::new(), target: "x86_64-unknown-linux-gnu".into(), - crate_type: "rlib".into(), + crate_type: CrateType::Rlib, + is_test: false, + build: None, }, ]), )