Merge deps of related crates in Rust Analyzer support (#781)

It can happen a single source file is present in multiple crates - there can
  be a `rust_library` with a `lib.rs` file, and a `rust_test` for the `test`
  module in that file. Tests can declare more dependencies than what library
  had. Therefore we had to collect all `RustAnalyzerInfo`s for a given crate
  and take deps from all of them.

  There's one exception - if the dependency is the same crate name as the
  the crate being processed, we don't add it as a dependency to itself. This is
  common and expected - `rust_test.crate` pointing to the `rust_library`.
diff --git a/rust/private/rust_analyzer.bzl b/rust/private/rust_analyzer.bzl
index ea9c6b2..ea1b7c6 100644
--- a/rust/private/rust_analyzer.bzl
+++ b/rust/private/rust_analyzer.bzl
@@ -44,7 +44,8 @@
 
     toolchain = find_toolchain(ctx)
 
-    # Always add test & debug_assertions (like here: https://github.com/rust-analyzer/rust-analyzer/blob/505ff4070a3de962dbde66f08b6550cda2eb4eab/crates/project_model/src/lib.rs#L379-L381)
+    # Always add test & debug_assertions (like here:
+    # https://github.com/rust-analyzer/rust-analyzer/blob/505ff4070a3de962dbde66f08b6550cda2eb4eab/crates/project_model/src/lib.rs#L379-L381)
     cfgs = ["test", "debug_assertions"]
     if hasattr(ctx.rule.attr, "crate_features"):
         cfgs += ['feature="{}"'.format(f) for f in ctx.rule.attr.crate_features]
@@ -116,51 +117,73 @@
     """
     return "ID-" + crate_info.root.path
 
-def create_crate(ctx, info, crate_mapping):
-    """Creates a crate in the rust-project.json format
+def _create_crate(ctx, infos, crate_mapping):
+    """Creates a crate in the rust-project.json format.
+
+    It can happen a single source file is present in multiple crates - there can
+    be a `rust_library` with a `lib.rs` file, and a `rust_test` for the `test`
+    module in that file. Tests can declare more dependencies than what library
+    had. Therefore we had to collect all RustAnalyzerInfos for a given crate
+    and take deps from all of them.
+
+    There's one exception - if the dependency is the same crate name as the
+    the crate being processed, we don't add it as a dependency to itself. This is
+    common and expected - `rust_test.crate` pointing to the `rust_library`.
 
     Args:
         ctx (ctx): The rule context
-        info (RustAnalyzerInfo): The crate RustAnalyzerInfo for the current crate
+        infos (list of RustAnalyzerInfos): RustAnalyzerInfos for the current crate
         crate_mapping (dict): A dict of {String:Int} that memoizes crates for deps.
 
     Returns:
         (dict) The crate rust-project.json representation
     """
+    if len(infos) == 0:
+        fail("Expected to receive at least one crate to serialize to json, got 0.")
+    canonical_info = infos[0]
+    crate_name = canonical_info.crate.name
     crate = dict()
-    crate["display_name"] = info.crate.name
-    crate["edition"] = info.crate.edition
+    crate["display_name"] = crate_name
+    crate["edition"] = canonical_info.crate.edition
     crate["env"] = {}
 
     # Switch on external/ to determine if crates are in the workspace or remote.
     # TODO: Some folks may want to override this for vendored dependencies.
-    if info.crate.root.path.startswith("external/"):
+    root_path = canonical_info.crate.root.path
+    root_dirname = canonical_info.crate.root.dirname
+    if root_path.startswith("external/"):
         crate["is_workspace_member"] = False
-        crate["root_module"] = _exec_root_tmpl + info.crate.root.path
-        crate_root = _exec_root_tmpl + info.crate.root.dirname
+        crate["root_module"] = _exec_root_tmpl + root_path
+        crate_root = _exec_root_tmpl + root_dirname
     else:
         crate["is_workspace_member"] = True
-        crate["root_module"] = info.crate.root.path
-        crate_root = info.crate.root.dirname
+        crate["root_module"] = root_path
+        crate_root = root_dirname
 
-    if info.build_info != None:
-        crate["env"].update({"OUT_DIR": _exec_root_tmpl + info.build_info.out_dir.path})
+    if canonical_info.build_info != None:
+        out_dir_path = canonical_info.build_info.out_dir.path
+        crate["env"].update({"OUT_DIR": _exec_root_tmpl + out_dir_path})
         crate["source"] = {
             # We have to tell rust-analyzer about our out_dir since it's not under the crate root.
             "exclude_dirs": [],
-            "include_dirs": [crate_root, _exec_root_tmpl + info.build_info.out_dir.path],
+            "include_dirs": [crate_root, _exec_root_tmpl + out_dir_path],
         }
-    crate["env"].update(info.env)
+    crate["env"].update(canonical_info.env)
 
-    deps = [
-        {"crate": crate_mapping[_crate_id(d.crate)], "name": d.crate.name}
-        for d in info.deps
-    ]
-    crate["deps"] = deps
-    crate["cfg"] = info.cfgs
+    # Collect deduplicated pairs of (crate idx from crate_mapping, crate name).
+    # Using dict because we don't have sets in Starlark.
+    deps = {
+        (crate_mapping[_crate_id(dep.crate)], dep.crate.name): None
+        for info in infos
+        for dep in info.deps
+        if dep.crate.name != crate_name
+    }.keys()
+
+    crate["deps"] = [{"crate": d[0], "name": d[1]} for d in deps]
+    crate["cfg"] = canonical_info.cfgs
     crate["target"] = find_toolchain(ctx).target_triple
-    if info.proc_macro_dylib_path != None:
-        crate["proc_macro_dylib_path"] = _exec_root_tmpl + info.proc_macro_dylib_path
+    if canonical_info.proc_macro_dylib_path != None:
+        crate["proc_macro_dylib_path"] = _exec_root_tmpl + canonical_info.proc_macro_dylib_path
     return crate
 
 # This implementation is incomplete because in order to get rustc env vars we
@@ -181,29 +204,33 @@
     if rust_toolchain.rustc_srcs.label.workspace_root:
         sysroot_src = _exec_root_tmpl + rust_toolchain.rustc_srcs.label.workspace_root + "/" + sysroot_src
 
-    # Gather all crates and their dependencies into an array.
-    # Dependencies are referenced by index, so leaves should come first.
-    crates = []
+    # Groups of RustAnalyzerInfos with the same _crate_id().
+    rust_analyzer_info_groups = []
+
+    # Dict from _crate_id() to the index of a RustAnalyzerInfo group in `rust_analyzer_info_groups`.
     crate_mapping = dict()
+
+    # Dependencies are referenced by index, so leaves should come first.
     idx = 0
     for target in ctx.attr.targets:
         if RustAnalyzerInfo not in target:
             continue
 
-        # Add this crate's transitive deps to the crate mapping and output.
-        for dep_info in target[RustAnalyzerInfo].transitive_deps.to_list():
-            crate_id = _crate_id(dep_info.crate)
+        for info in depset(
+            direct = [target[RustAnalyzerInfo]],
+            transitive = [target[RustAnalyzerInfo].transitive_deps],
+            order = "postorder",
+        ).to_list():
+            crate_id = _crate_id(info.crate)
             if crate_id not in crate_mapping:
                 crate_mapping[crate_id] = idx
+                rust_analyzer_info_groups.append([])
                 idx += 1
-                crates.append(create_crate(ctx, dep_info, crate_mapping))
+            rust_analyzer_info_groups[crate_mapping[crate_id]].append(info)
 
-        # Add this crate to the crate mapping and output.
-        crate_id = _crate_id(target[RustAnalyzerInfo].crate)
-        if crate_id not in crate_mapping:
-            crate_mapping[crate_id] = idx
-            idx += 1
-            crates.append(create_crate(ctx, target[RustAnalyzerInfo], crate_mapping))
+    crates = []
+    for group in rust_analyzer_info_groups:
+        crates.append(_create_crate(ctx, group, crate_mapping))
 
     # TODO(djmarcin): Use json module once bazel 4.0 is released.
     ctx.actions.write(output = ctx.outputs.filename, content = struct(
diff --git a/test/rust_analyzer/BUILD.bazel b/test/rust_analyzer/aspect_traversal_test/BUILD.bazel
similarity index 100%
rename from test/rust_analyzer/BUILD.bazel
rename to test/rust_analyzer/aspect_traversal_test/BUILD.bazel
diff --git a/test/rust_analyzer/extra_proc_macro_dep.rs b/test/rust_analyzer/aspect_traversal_test/extra_proc_macro_dep.rs
similarity index 100%
rename from test/rust_analyzer/extra_proc_macro_dep.rs
rename to test/rust_analyzer/aspect_traversal_test/extra_proc_macro_dep.rs
diff --git a/test/rust_analyzer/extra_test_dep.rs b/test/rust_analyzer/aspect_traversal_test/extra_test_dep.rs
similarity index 100%
rename from test/rust_analyzer/extra_test_dep.rs
rename to test/rust_analyzer/aspect_traversal_test/extra_test_dep.rs
diff --git a/test/rust_analyzer/lib_dep.rs b/test/rust_analyzer/aspect_traversal_test/lib_dep.rs
similarity index 100%
rename from test/rust_analyzer/lib_dep.rs
rename to test/rust_analyzer/aspect_traversal_test/lib_dep.rs
diff --git a/test/rust_analyzer/mylib.rs b/test/rust_analyzer/aspect_traversal_test/mylib.rs
similarity index 100%
rename from test/rust_analyzer/mylib.rs
rename to test/rust_analyzer/aspect_traversal_test/mylib.rs
diff --git a/test/rust_analyzer/proc_macro_dep.rs b/test/rust_analyzer/aspect_traversal_test/proc_macro_dep.rs
similarity index 100%
rename from test/rust_analyzer/proc_macro_dep.rs
rename to test/rust_analyzer/aspect_traversal_test/proc_macro_dep.rs
diff --git a/test/rust_analyzer/rust_project_json_test.rs b/test/rust_analyzer/aspect_traversal_test/rust_project_json_test.rs
similarity index 79%
rename from test/rust_analyzer/rust_project_json_test.rs
rename to test/rust_analyzer/aspect_traversal_test/rust_project_json_test.rs
index 758adee..731e2c6 100644
--- a/test/rust_analyzer/rust_project_json_test.rs
+++ b/test/rust_analyzer/aspect_traversal_test/rust_project_json_test.rs
@@ -2,7 +2,8 @@
 
 fn main() {
     let r = Runfiles::create().unwrap();
-    let rust_project_path = r.rlocation("rules_rust/test/rust_analyzer/rust-project.json");
+    let rust_project_path =
+        r.rlocation("rules_rust/test/rust_analyzer/aspect_traversal_test/rust-project.json");
 
     let content = std::fs::read_to_string(&rust_project_path)
         .expect(&format!("couldn't open {:?}", &rust_project_path));
diff --git a/test/rust_analyzer/merging_crates_test/BUILD.bazel b/test/rust_analyzer/merging_crates_test/BUILD.bazel
new file mode 100644
index 0000000..a54e193
--- /dev/null
+++ b/test/rust_analyzer/merging_crates_test/BUILD.bazel
@@ -0,0 +1,41 @@
+load("//rust:defs.bzl", "rust_analyzer", "rust_library", "rust_test")
+
+rust_library(
+    name = "mylib",
+    srcs = ["mylib.rs"],
+    deps = [":lib_dep"],
+)
+
+rust_library(
+    name = "lib_dep",
+    srcs = ["lib_dep.rs"],
+)
+
+rust_test(
+    name = "mylib_test",
+    crate = ":mylib",
+    deps = [":extra_test_dep"],
+)
+
+rust_library(
+    name = "extra_test_dep",
+    srcs = ["extra_test_dep.rs"],
+)
+
+rust_analyzer(
+    name = "rust_analyzer",
+    testonly = True,
+    targets = [
+        # it's significant that `mylib` goes before `mylib_test`.
+        ":mylib",
+        ":mylib_test",
+    ],
+)
+
+rust_test(
+    name = "rust_project_json_test",
+    srcs = ["rust_project_json_test.rs"],
+    data = [":rust-project.json"],
+    edition = "2018",
+    deps = ["//tools/runfiles"],
+)
diff --git a/test/rust_analyzer/extra_test_dep.rs b/test/rust_analyzer/merging_crates_test/extra_test_dep.rs
similarity index 100%
copy from test/rust_analyzer/extra_test_dep.rs
copy to test/rust_analyzer/merging_crates_test/extra_test_dep.rs
diff --git a/test/rust_analyzer/lib_dep.rs b/test/rust_analyzer/merging_crates_test/lib_dep.rs
similarity index 100%
copy from test/rust_analyzer/lib_dep.rs
copy to test/rust_analyzer/merging_crates_test/lib_dep.rs
diff --git a/test/rust_analyzer/mylib.rs b/test/rust_analyzer/merging_crates_test/mylib.rs
similarity index 100%
copy from test/rust_analyzer/mylib.rs
copy to test/rust_analyzer/merging_crates_test/mylib.rs
diff --git a/test/rust_analyzer/merging_crates_test/rust_project_json_test.rs b/test/rust_analyzer/merging_crates_test/rust_project_json_test.rs
new file mode 100644
index 0000000..fdb7be9
--- /dev/null
+++ b/test/rust_analyzer/merging_crates_test/rust_project_json_test.rs
@@ -0,0 +1,20 @@
+use runfiles::Runfiles;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_deps_of_crate_and_its_test_are_merged() {
+        let r = Runfiles::create().unwrap();
+        let rust_project_path =
+            r.rlocation("rules_rust/test/rust_analyzer/merging_crates_test/rust-project.json");
+
+        let content = std::fs::read_to_string(&rust_project_path)
+            .expect(&format!("couldn't open {:?}", &rust_project_path));
+
+        assert!(
+            content.contains(r#""root_module":"test/rust_analyzer/merging_crates_test/mylib.rs","deps":[{"crate":0,"name":"lib_dep"},{"crate":2,"name":"extra_test_dep"}]"#),
+            "expected rust-project.json to contain both lib_dep and extra_test_dep in deps of mylib.rs.");
+    }
+}
diff --git a/test/rust_analyzer/merging_crates_test_reversed/BUILD.bazel b/test/rust_analyzer/merging_crates_test_reversed/BUILD.bazel
new file mode 100644
index 0000000..8cdd7f5
--- /dev/null
+++ b/test/rust_analyzer/merging_crates_test_reversed/BUILD.bazel
@@ -0,0 +1,41 @@
+load("//rust:defs.bzl", "rust_analyzer", "rust_library", "rust_test")
+
+rust_library(
+    name = "mylib",
+    srcs = ["mylib.rs"],
+    deps = [":lib_dep"],
+)
+
+rust_library(
+    name = "lib_dep",
+    srcs = ["lib_dep.rs"],
+)
+
+rust_test(
+    name = "mylib_test",
+    crate = ":mylib",
+    deps = [":extra_test_dep"],
+)
+
+rust_library(
+    name = "extra_test_dep",
+    srcs = ["extra_test_dep.rs"],
+)
+
+rust_analyzer(
+    name = "rust_analyzer",
+    testonly = True,
+    targets = [
+        # it's significant that `mylib_test` goes before `mylib`.
+        ":mylib_test",
+        ":mylib",
+    ],
+)
+
+rust_test(
+    name = "rust_project_json_test",
+    srcs = ["rust_project_json_test.rs"],
+    data = [":rust-project.json"],
+    edition = "2018",
+    deps = ["//tools/runfiles"],
+)
diff --git a/test/rust_analyzer/extra_test_dep.rs b/test/rust_analyzer/merging_crates_test_reversed/extra_test_dep.rs
similarity index 100%
copy from test/rust_analyzer/extra_test_dep.rs
copy to test/rust_analyzer/merging_crates_test_reversed/extra_test_dep.rs
diff --git a/test/rust_analyzer/lib_dep.rs b/test/rust_analyzer/merging_crates_test_reversed/lib_dep.rs
similarity index 100%
copy from test/rust_analyzer/lib_dep.rs
copy to test/rust_analyzer/merging_crates_test_reversed/lib_dep.rs
diff --git a/test/rust_analyzer/mylib.rs b/test/rust_analyzer/merging_crates_test_reversed/mylib.rs
similarity index 100%
copy from test/rust_analyzer/mylib.rs
copy to test/rust_analyzer/merging_crates_test_reversed/mylib.rs
diff --git a/test/rust_analyzer/merging_crates_test_reversed/rust_project_json_test.rs b/test/rust_analyzer/merging_crates_test_reversed/rust_project_json_test.rs
new file mode 100644
index 0000000..036b816
--- /dev/null
+++ b/test/rust_analyzer/merging_crates_test_reversed/rust_project_json_test.rs
@@ -0,0 +1,21 @@
+use runfiles::Runfiles;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_deps_of_crate_and_its_test_are_merged() {
+        let r = Runfiles::create().unwrap();
+        let rust_project_path = r.rlocation(
+            "rules_rust/test/rust_analyzer/merging_crates_test_reversed/rust-project.json",
+        );
+
+        let content = std::fs::read_to_string(&rust_project_path)
+            .expect(&format!("couldn't open {:?}", &rust_project_path));
+
+        assert!(
+            content.contains(r#""root_module":"test/rust_analyzer/merging_crates_test_reversed/mylib.rs","deps":[{"crate":0,"name":"lib_dep"},{"crate":1,"name":"extra_test_dep"}]"#),
+            "expected rust-project.json to contain both lib_dep and extra_test_dep in deps of mylib.rs.");
+    }
+}