From b0fd5932a1ad947ae7af25c7fa1fdeb3566b57da Mon Sep 17 00:00:00 2001 From: runchen0919 Date: Thu, 14 May 2026 17:35:09 +0800 Subject: [PATCH 1/2] refactor(graph): unify source JAR resolution into ResolvedJar with multi-strategy chain Merge separate target_jars and target_source_jars maps into a single ResolvedJar struct that pairs each classpath JAR with its source attachment. Introduce build_resolved_jars() with a 6-strategy resolution chain: class_to_source, interface_to_source, source_by_dir, single_source, infer_source, convention_probe, and maven_cache fallback. Key changes: - Add ResolvedJar struct pairing classpath_path with source_path - Remove get_target_source_jar() in favor of per-jar source_path - Filter stub source JARs (< 1KB) and probe ~/.m2 as fallback - Deduplicate classpath entries and merge source from later targets - Add placeholder native libs in debug build script for bnd validation - Enable bazel_graph=info logging for source resolution diagnostics Co-Authored-By: Claude Opus 4.6 --- .../crates/bazel-graph/src/classpath.rs | 227 ++-- .../crates/bazel-graph/src/graph.rs | 1036 ++++++++++++++--- .../crates/bazel-graph/src/lib.rs | 2 +- .../crates/bazel-jdt-core/src/jni_exports.rs | 3 +- .../bazel-jdt-core/tests/integration.rs | 42 +- bazel-jdt-bridge/scripts/build-for-debug.sh | 21 + 6 files changed, 1042 insertions(+), 289 deletions(-) diff --git a/bazel-jdt-bridge/crates/bazel-graph/src/classpath.rs b/bazel-jdt-bridge/crates/bazel-graph/src/classpath.rs index 55bdac5..52d7051 100644 --- a/bazel-jdt-bridge/crates/bazel-graph/src/classpath.rs +++ b/bazel-jdt-bridge/crates/bazel-graph/src/classpath.rs @@ -1,4 +1,4 @@ -use crate::graph::{DependencyGraph, GraphError}; +use crate::graph::{infer_source_attachment, DependencyGraph, GraphError}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -195,13 +195,12 @@ impl ComputedClasspath { let deps = graph.transitive_deps(target_label)?; let mut entries = Vec::new(); - let mut seen_jars = std::collections::HashSet::new(); + let mut seen_jars: std::collections::HashMap = + std::collections::HashMap::new(); for dep_label in &deps { let dep_is_testonly = is_test_context && graph.is_testonly(dep_label); - // Bazel 6+ uses "@@" canonical labels for external repos. Only skip - // toolchain/platform targets — Maven deps etc. must pass through. if is_bazel_internal_label(dep_label) { continue; } @@ -222,25 +221,34 @@ impl ComputedClasspath { if let Some(jars) = graph.get_target_jars(dep_label) { for jar in jars { - if seen_jars.insert(jar.clone()) { - let source_path = graph.get_target_source_jar(dep_label, jar); - let effective_source = if let Some(ref sp) = source_path { - if std::path::Path::new(sp).exists() { - source_path - } else if is_workspace_internal { - infer_source_attachment(dep_label, workspace_root) - } else { - None + let resolve_source = |jar: &crate::graph::ResolvedJar| { + jar.source_path + .as_ref() + .filter(|p| std::path::Path::new(p).exists()) + .cloned() + .or_else(|| { + if is_workspace_internal { + infer_source_attachment(dep_label, workspace_root) + } else { + None + } + }) + }; + + if let Some(&existing_idx) = seen_jars.get(&jar.classpath_path) { + if entries[existing_idx].source_attachment_path.is_none() { + let source = resolve_source(jar); + if source.is_some() { + entries[existing_idx].source_attachment_path = source; } - } else if is_workspace_internal { - infer_source_attachment(dep_label, workspace_root) - } else { - None - }; + } + } else { + let source = resolve_source(jar); + seen_jars.insert(jar.classpath_path.clone(), entries.len()); entries.push(ClasspathEntry { entry_type: ClasspathEntryType::Library, - path: jar.clone(), - source_attachment_path: effective_source, + path: jar.classpath_path.clone(), + source_attachment_path: source, is_test: dep_is_testonly, is_exported: false, access_rules: Vec::new(), @@ -253,7 +261,7 @@ impl ComputedClasspath { let output_jars = graph .get_target_jars(target_label) - .cloned() + .map(|jars| jars.iter().map(|j| j.classpath_path.clone()).collect()) .unwrap_or_default(); Ok(ComputedClasspath { @@ -277,11 +285,15 @@ impl ComputedClasspath { if let Some(jars) = graph.get_target_jars(target_label) { for jar in jars { - let source_path = graph.get_target_source_jar(target_label, jar); + let source = jar + .source_path + .as_ref() + .filter(|p| std::path::Path::new(p).exists()) + .cloned(); entries.push(ClasspathEntry { entry_type: ClasspathEntryType::Library, - path: jar.clone(), - source_attachment_path: source_path, + path: jar.classpath_path.clone(), + source_attachment_path: source, is_test: false, is_exported: false, access_rules: Vec::new(), @@ -292,7 +304,7 @@ impl ComputedClasspath { let output_jars = graph .get_target_jars(target_label) - .cloned() + .map(|jars| jars.iter().map(|j| j.classpath_path.clone()).collect()) .unwrap_or_default(); Ok(ComputedClasspath { @@ -366,64 +378,6 @@ impl ComputedClasspath { } } -fn pkg_contains_java_content(dir: &std::path::Path) -> bool { - dir.read_dir() - .ok() - .map(|mut entries| { - entries.any(|e| { - e.map(|e| { - let name = e.file_name(); - let name_str = name.to_string_lossy(); - name_str.ends_with(".java") - }) - .unwrap_or(false) - }) - }) - .unwrap_or(false) -} - -fn infer_source_attachment(dep_label: &str, workspace_root: Option<&str>) -> Option { - let ws_root = workspace_root?; - let label = dep_label.strip_prefix("//")?; - let package_path = label.split(':').next().unwrap_or(label); - if package_path.is_empty() { - return None; - } - - let source_root_markers = ["src/main/java", "src/test/java", "src/java", "java"]; - let pkg = std::path::Path::new(ws_root).join(package_path); - - // Probe filesystem: // - for marker in &source_root_markers { - let candidate = pkg.join(marker); - if candidate.is_dir() { - return Some(candidate.to_string_lossy().into_owned()); - } - } - - // Fallback: package directory itself (flat layouts) - if pkg.is_dir() && pkg_contains_java_content(&pkg) { - return Some(pkg.to_string_lossy().into_owned()); - } - - // Substring fallback for labels with embedded source paths - let substring_markers = [ - "src/main/java/", - "src/test/java/", - "src/java/", - "javatests/", - "java/", - ]; - for marker in &substring_markers { - if let Some(idx) = package_path.find(marker) { - let root = &package_path[..idx + marker.len() - 1]; - return Some(format!("{}/{}", ws_root, root)); - } - } - - None -} - /// Returns true for Bazel-internal toolchain/platform targets that should never /// appear on a Java classpath. In Bazel 6+, canonical repo labels use "@@" prefix. /// External dependencies like Maven artifacts (e.g. `@@maven+...//:guava`) must NOT @@ -437,6 +391,7 @@ pub fn is_bazel_internal_label(label: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::graph::{infer_source_attachment, ResolvedJar}; use bazel_aspect::{ArtifactLocation, JarInfo, JavaIdeInfo, TargetIdeInfo}; use std::path::Path; @@ -987,7 +942,10 @@ mod tests { graph.add_target("@@rules_jvm_external~maven~maven//:guava"); graph.set_target_jars( "@@rules_jvm_external~maven~maven//:guava", - vec!["/guava.jar".to_string()], + vec![ResolvedJar { + classpath_path: "/guava.jar".to_string(), + source_path: None, + }], ); graph.label_aliases.insert( "@maven//:guava".to_string(), @@ -996,7 +954,7 @@ mod tests { let jars = graph.get_target_jars("@maven//:guava"); assert!(jars.is_some()); - assert_eq!(jars.unwrap()[0], "/guava.jar"); + assert_eq!(jars.unwrap()[0].classpath_path, "/guava.jar"); } #[test] @@ -1010,7 +968,10 @@ mod tests { graph.add_target("@@rules_jvm_external~maven~maven//:com_google_guava_guava"); graph.set_target_jars( "@@rules_jvm_external~maven~maven//:com_google_guava_guava", - vec!["/guava-33.4.0-jre.jar".to_string()], + vec![ResolvedJar { + classpath_path: "/guava-33.4.0-jre.jar".to_string(), + source_path: None, + }], ); graph.label_aliases.insert( "@maven//:com_google_guava_guava".to_string(), @@ -1151,8 +1112,8 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let guava_jar = tmp.path().join("guava.jar"); let src_jar = tmp.path().join("guava-sources.jar"); - std::fs::File::create(&guava_jar).unwrap(); - std::fs::File::create(&src_jar).unwrap(); + std::fs::write(&guava_jar, [0u8; 2048]).unwrap(); + std::fs::write(&src_jar, [0u8; 2048]).unwrap(); let mut graph = DependencyGraph::new(); let results = vec![ @@ -1302,8 +1263,8 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let guava_jar = tmp.path().join("guava.jar"); let src_jar = tmp.path().join("guava-sources.jar"); - std::fs::File::create(&guava_jar).unwrap(); - std::fs::File::create(&src_jar).unwrap(); + std::fs::write(&guava_jar, [0u8; 2048]).unwrap(); + std::fs::write(&src_jar, [0u8; 2048]).unwrap(); let mut graph = DependencyGraph::new(); let results = vec![ @@ -1461,8 +1422,8 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let binary_jar = tmp.path().join("lib.jar"); let source_jar = tmp.path().join("lib-sources.jar"); - std::fs::File::create(&binary_jar).unwrap(); - std::fs::File::create(&source_jar).unwrap(); + std::fs::write(&binary_jar, [0u8; 2048]).unwrap(); + std::fs::write(&source_jar, [0u8; 2048]).unwrap(); let mut graph = DependencyGraph::new(); let results = vec![ @@ -1562,4 +1523,86 @@ mod tests { "Phantom source JAR for workspace-internal dep should fall back to infer_source_attachment" ); } + + #[test] + fn test_duplicate_jar_merges_source_from_later_target() { + let tmp = tempfile::tempdir().unwrap(); + let guava_jar = tmp.path().join("guava.jar"); + let src_jar = tmp.path().join("guava-sources.jar"); + std::fs::write(&guava_jar, [0u8; 2048]).unwrap(); + std::fs::write(&src_jar, [0u8; 2048]).unwrap(); + + let guava_path = guava_jar.to_str().unwrap(); + let src_path = src_jar.to_str().unwrap(); + + let mut graph = DependencyGraph::new(); + let results = vec![ + make_target("//app:app", vec!["//3rdparty:guava"], vec!["/app.jar"]), + make_target_with_jar_path("//3rdparty:guava", vec!["@maven//:guava"], guava_path), + make_target_with_source_jar("@maven//:guava", vec![], guava_path, src_path), + ]; + graph.populate_from_aspects(&results, Path::new(tmp.path())); + + let ws = tmp.path().to_string_lossy().into_owned(); + let cp = + ComputedClasspath::compute_for(&graph, "//app:app", TargetKind::JavaLibrary, Some(&ws)) + .unwrap(); + + let jar_entries: Vec<_> = cp.entries.iter().filter(|e| e.path == guava_path).collect(); + assert_eq!( + jar_entries.len(), + 1, + "Duplicate JAR should appear only once" + ); + assert_eq!( + jar_entries[0].source_attachment_path, + Some(src_path.to_string()), + "Source from later @maven// target should be merged into entry from //3rdparty: wrapper" + ); + } + + #[test] + fn test_duplicate_jar_preserves_first_valid_source() { + let tmp = tempfile::tempdir().unwrap(); + let guava_jar = tmp.path().join("guava.jar"); + let src1_jar = tmp.path().join("src1-sources.jar"); + let src2_jar = tmp.path().join("src2-sources.jar"); + std::fs::write(&guava_jar, [0u8; 2048]).unwrap(); + std::fs::write(&src1_jar, [0u8; 2048]).unwrap(); + std::fs::write(&src2_jar, [0u8; 2048]).unwrap(); + + let guava_path = guava_jar.to_str().unwrap(); + let src1_path = src1_jar.to_str().unwrap(); + let src2_path = src2_jar.to_str().unwrap(); + + let mut graph = DependencyGraph::new(); + let results = vec![ + make_target("//app:app", vec!["@repo_a//:guava"], vec!["/app.jar"]), + make_target_with_source_jar( + "@repo_a//:guava", + vec!["@repo_b//:guava"], + guava_path, + src1_path, + ), + make_target_with_source_jar("@repo_b//:guava", vec![], guava_path, src2_path), + ]; + graph.populate_from_aspects(&results, Path::new(tmp.path())); + + let ws = tmp.path().to_string_lossy().into_owned(); + let cp = + ComputedClasspath::compute_for(&graph, "//app:app", TargetKind::JavaLibrary, Some(&ws)) + .unwrap(); + + let jar_entries: Vec<_> = cp.entries.iter().filter(|e| e.path == guava_path).collect(); + assert_eq!( + jar_entries.len(), + 1, + "Duplicate JAR should appear only once" + ); + assert_eq!( + jar_entries[0].source_attachment_path, + Some(src1_path.to_string()), + "First valid source should be preserved, not overwritten by later target" + ); + } } diff --git a/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs b/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs index d0c962b..dfaf9db 100644 --- a/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs +++ b/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs @@ -3,14 +3,19 @@ use petgraph::visit::EdgeRef; use petgraph::Direction; use std::collections::{HashMap, HashSet, VecDeque}; +/// A classpath JAR paired with its resolved source attachment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedJar { + pub classpath_path: String, + pub source_path: Option, +} + /// Dependency graph of Bazel targets pub struct DependencyGraph { graph: DiGraph, label_to_index: HashMap, - /// JARs associated with each target - target_jars: HashMap>, - /// Source JAR mappings per target: {target_label → {binary_jar_path → source_jar_path}} - target_source_jars: HashMap>, + /// JARs associated with each target, each carrying its resolved source attachment + target_jars: HashMap>, /// Targets that have `testonly = True` in their Bazel rule definition. /// These targets can only be depended on by test targets. testonly_targets: HashSet, @@ -35,7 +40,6 @@ impl DependencyGraph { graph: DiGraph::new(), label_to_index: HashMap::new(), target_jars: HashMap::new(), - target_source_jars: HashMap::new(), testonly_targets: HashSet::new(), label_aliases: HashMap::new(), } @@ -68,8 +72,8 @@ impl DependencyGraph { } } - /// Associate JARs with a target - pub fn set_target_jars(&mut self, label: &str, jars: Vec) { + /// Associate resolved JARs (with source attachments) with a target + pub fn set_target_jars(&mut self, label: &str, jars: Vec) { self.add_target(label); self.target_jars.insert(label.to_string(), jars); } @@ -131,7 +135,7 @@ impl DependencyGraph { } /// Get JARs for a target, resolving through the alias map if needed - pub fn get_target_jars(&self, label: &str) -> Option<&Vec> { + pub fn get_target_jars(&self, label: &str) -> Option<&Vec> { if let Some(jars) = self.target_jars.get(label) { return Some(jars); } @@ -141,22 +145,6 @@ impl DependencyGraph { None } - /// Get the source JAR path for a specific binary JAR of a target. - /// Resolves through label_aliases like get_target_jars(). - pub fn get_target_source_jar(&self, label: &str, binary_jar: &str) -> Option { - if let Some(sources) = self.target_source_jars.get(label) { - if let Some(src) = sources.get(binary_jar) { - return Some(src.clone()); - } - } - if let Some(canonical) = self.label_aliases.get(label) { - if let Some(sources) = self.target_source_jars.get(canonical) { - return sources.get(binary_jar).cloned(); - } - } - None - } - /// Check if a target has `testonly = True`. pub fn is_testonly(&self, label: &str) -> bool { self.testonly_targets.contains(label) @@ -172,7 +160,6 @@ impl DependencyGraph { self.graph = DiGraph::new(); self.label_to_index.clear(); self.target_jars.clear(); - self.target_source_jars.clear(); self.testonly_targets.clear(); self.label_aliases.clear(); } @@ -206,35 +193,15 @@ impl DependencyGraph { let mut jars: Vec = java_info .jars .iter() - .filter_map(|j| { - let path = j.jar.best_path()?; - Some(if j.jar.is_source { - resolve_external_path(&path, workspace_root).unwrap_or(path) - } else { - path - }) - }) + .filter_map(|j| normalize_artifact_path(&j.jar, workspace_root)) .collect(); let compile_jars: Vec = java_info .compile_jars .iter() - .filter_map(|j| { - let path = j.best_path()?; - Some(if j.is_source { - resolve_external_path(&path, workspace_root).unwrap_or(path) - } else { - path - }) - }) + .filter_map(|j| normalize_artifact_path(j, workspace_root)) .collect(); - // Prefer compile_jars over jars when: - // - jars is empty (java_import targets), OR - // - jars only contains derived non-external artifacts and - // compile_jars is non-empty. This covers 3rdparty wrappers - // (compile_jars = Maven JAR) AND Thrift/Avro/internal targets - // (compile_jars = header JAR that exists on disk). let jars_all_derived_internal = !jars.is_empty() && java_info .jars @@ -245,60 +212,8 @@ impl DependencyGraph { } if !jars.is_empty() { - self.set_target_jars(label, jars); - } - - let mut source_map: HashMap = HashMap::new(); - for jar_info in &java_info.jars { - if let (Some(bin_path), Some(src_path)) = ( - jar_info.jar.best_path(), - jar_info.source_jar.as_ref().and_then(|s| s.best_path()), - ) { - let src_is_source = - jar_info.source_jar.as_ref().map_or(true, |s| s.is_source); - let resolved_src = if src_is_source { - resolve_external_path(&src_path, workspace_root).unwrap_or(src_path) - } else { - src_path - }; - source_map.insert(bin_path, resolved_src); - } - } - - // When jars is empty (compile_jars fallback for java_import targets), - // match source_jars to compile_jars by parent directory (Maven coordinates). - if source_map.is_empty() && !java_info.source_jars.is_empty() { - let source_by_dir: HashMap = java_info - .source_jars - .iter() - .filter_map(|s| { - let p = s.best_path()?; - let resolved = if s.is_source { - resolve_external_path(&p, workspace_root).unwrap_or(p) - } else { - p - }; - Some((parent_key(&resolved), resolved)) - }) - .collect(); - - for bin_jar in self.target_jars.get(label).into_iter().flatten() { - if let Some(src_path) = source_by_dir.get(&parent_key(bin_jar)) { - source_map.insert(bin_jar.clone(), src_path.clone()); - } - } - } - - if !source_map.is_empty() { - log::debug!( - "[bazel-jdt] source_map for '{}': {} entries", - label, - source_map.len() - ); - for (bin, src) in &source_map { - log::trace!("[bazel-jdt] {} -> {}", bin, src); - } - self.target_source_jars.insert(label.clone(), source_map); + let resolved = build_resolved_jars(java_info, &jars, workspace_root, label); + self.set_target_jars(label, resolved); } let pkg = package_of(label); @@ -420,7 +335,6 @@ impl DependencyGraph { self.graph.remove_node(idx); } self.target_jars.remove(label); - self.target_source_jars.remove(label); self.testonly_targets.remove(label); removed.push(label.clone()); } @@ -660,6 +574,317 @@ fn parent_key(path: &str) -> String { .unwrap_or_default() } +fn normalize_artifact_path( + artifact: &bazel_aspect::ArtifactLocation, + workspace_root: &std::path::Path, +) -> Option { + let path = artifact.best_path()?; + Some(if artifact.is_source { + resolve_external_path(&path, workspace_root).unwrap_or(path) + } else { + path + }) +} + +/// Build `Vec` using a 6-strategy source resolution chain. +fn build_resolved_jars( + java_info: &bazel_aspect::JavaIdeInfo, + classpath_jars: &[String], + workspace_root: &std::path::Path, + label: &str, +) -> Vec { + let mut class_to_source: HashMap = HashMap::new(); + let mut interface_to_source: HashMap = HashMap::new(); + let mut all_source_jars: HashSet = HashSet::new(); + + for jar_info in &java_info.jars { + if let Some(src_loc) = jar_info.source_jar.as_ref() { + if let Some(src_path) = src_loc.best_path() { + let resolved_src = if src_loc.is_source { + resolve_external_path(&src_path, workspace_root).unwrap_or(src_path) + } else { + absolutize_source_path(&src_path, workspace_root) + }; + all_source_jars.insert(resolved_src.clone()); + + if let Some(key) = normalize_artifact_path(&jar_info.jar, workspace_root) { + class_to_source.insert(key, resolved_src.clone()); + } + if let Some(iface_loc) = jar_info.interface_jar.as_ref() { + if let Some(key) = normalize_artifact_path(iface_loc, workspace_root) { + interface_to_source.insert(key, resolved_src.clone()); + } + } + } + } + } + + let source_by_dir: HashMap = java_info + .source_jars + .iter() + .filter_map(|s| { + let p = s.best_path()?; + let resolved = if s.is_source { + resolve_external_path(&p, workspace_root).unwrap_or(p) + } else { + absolutize_source_path(&p, workspace_root) + }; + all_source_jars.insert(resolved.clone()); + Some((parent_key(&resolved), resolved)) + }) + .collect(); + + let single_source = if all_source_jars.len() == 1 && classpath_jars.len() == 1 { + all_source_jars.into_iter().next() + } else { + None + }; + + let ws_str = workspace_root.to_str().unwrap_or(""); + + let mut stubs_discarded: u32 = 0; + let mut maven_cache_hits: u32 = 0; + let mut with_source: u32 = 0; + + let result = classpath_jars + .iter() + .map(|jar_path| { + let mut strategy = ""; + let source_path = class_to_source + .get(jar_path) + .cloned() + .and_then(|p| validate_source_jar(&p)) + .map(|p| { + strategy = "class_to_source"; + p + }) + .or_else(|| { + interface_to_source + .get(jar_path) + .cloned() + .and_then(|p| validate_source_jar(&p)) + .map(|p| { + strategy = "interface_to_source"; + p + }) + }) + .or_else(|| { + source_by_dir + .get(&parent_key(jar_path)) + .cloned() + .and_then(|p| validate_source_jar(&p)) + .map(|p| { + strategy = "source_by_dir"; + p + }) + }) + .or_else(|| { + single_source + .clone() + .and_then(|p| validate_source_jar(&p)) + .map(|p| { + strategy = "single_source"; + p + }) + }) + .or_else(|| { + if !label.starts_with('@') { + infer_source_attachment(label, Some(ws_str)).map(|p| { + strategy = "infer_source"; + p + }) + } else { + None + } + }) + .or_else(|| { + probe_source_jar_by_convention(jar_path) + .and_then(|p| validate_source_jar(&p)) + .map(|p| { + strategy = "convention_probe"; + p + }) + }) + .or_else(|| { + let (g, a, v) = extract_maven_coordinates(jar_path)?; + let result = probe_maven_local_cache(&g, &a, &v); + if result.is_some() { + strategy = "maven_cache"; + maven_cache_hits += 1; + } + result + }); + + if let Some(ref src) = source_path { + log::info!( + "[bazel-jdt] {} -> source via {}: {}", + jar_path, + strategy, + src + ); + } + + if source_path.is_none() + && class_to_source.contains_key(jar_path) + && validate_source_jar(class_to_source.get(jar_path).unwrap()).is_none() + { + stubs_discarded += 1; + } + + if source_path.is_some() { + with_source += 1; + } + + ResolvedJar { + classpath_path: jar_path.clone(), + source_path, + } + }) + .collect(); + + log::info!( + "[bazel-jdt] Source resolution for '{}': {}/{} with source, {} stubs discarded, {} maven cache hits", + label, + with_source, + classpath_jars.len(), + stubs_discarded, + maven_cache_hits + ); + + result +} + +fn validate_source_jar(path: &str) -> Option { + match std::fs::metadata(path) { + Ok(meta) if meta.len() < 1024 => None, + Ok(_) => Some(path.to_string()), + Err(_) => None, + } +} + +fn extract_maven_coordinates(jar_path: &str) -> Option<(String, String, String)> { + let maven_idx = jar_path + .rfind("/maven2/") + .map(|i| i + 8) + .or_else(|| jar_path.rfind("/maven/").map(|i| i + 7))?; + let after_maven = &jar_path[maven_idx..]; + + let parts: Vec<&str> = after_maven.split('/').collect(); + if parts.len() < 4 { + return None; + } + + let filename = *parts.last()?; + if !filename.ends_with(".jar") { + return None; + } + + let version = parts[parts.len() - 2]; + let artifact_id = parts[parts.len() - 3]; + let group_parts = &parts[..parts.len() - 3]; + if group_parts.is_empty() { + return None; + } + let group_id = group_parts.join("."); + + Some((group_id, artifact_id.to_string(), version.to_string())) +} + +fn probe_maven_local_cache(group_id: &str, artifact_id: &str, version: &str) -> Option { + let home = std::env::var("HOME").ok()?; + let group_path = group_id.replace('.', "/"); + let candidate = std::path::PathBuf::from(&home) + .join(".m2/repository") + .join(&group_path) + .join(artifact_id) + .join(version) + .join(format!("{}-{}-sources.jar", artifact_id, version)); + if candidate.exists() { + Some(candidate.to_string_lossy().into_owned()) + } else { + None + } +} + +fn absolutize_source_path(path: &str, workspace_root: &std::path::Path) -> String { + if path.starts_with('/') { + return path.to_string(); + } + if path.starts_with("bazel-out/") { + return workspace_root.join(path).to_string_lossy().into_owned(); + } + path.to_string() +} + +fn probe_source_jar_by_convention(jar_path: &str) -> Option { + let path = std::path::Path::new(jar_path); + let stem = path.file_stem()?.to_str()?; + let parent = path.parent()?; + let candidate = parent.join(format!("{}-sources.jar", stem)); + if candidate.exists() { + return Some(candidate.to_string_lossy().into_owned()); + } + None +} + +fn pkg_contains_java_content(dir: &std::path::Path) -> bool { + dir.read_dir() + .ok() + .map(|mut entries| { + entries.any(|e| { + e.map(|e| { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + name_str.ends_with(".java") + }) + .unwrap_or(false) + }) + }) + .unwrap_or(false) +} + +pub(crate) fn infer_source_attachment( + dep_label: &str, + workspace_root: Option<&str>, +) -> Option { + let ws_root = workspace_root?; + let label = dep_label.strip_prefix("//")?; + let package_path = label.split(':').next().unwrap_or(label); + if package_path.is_empty() { + return None; + } + + let source_root_markers = ["src/main/java", "src/test/java", "src/java", "java"]; + let pkg = std::path::Path::new(ws_root).join(package_path); + + for marker in &source_root_markers { + let candidate = pkg.join(marker); + if candidate.is_dir() { + return Some(candidate.to_string_lossy().into_owned()); + } + } + + if pkg.is_dir() && pkg_contains_java_content(&pkg) { + return Some(pkg.to_string_lossy().into_owned()); + } + + let substring_markers = [ + "src/main/java/", + "src/test/java/", + "src/java/", + "javatests/", + "java/", + ]; + for marker in &substring_markers { + if let Some(idx) = package_path.find(marker) { + let root = &package_path[..idx + marker.len() - 1]; + return Some(format!("{}/{}", ws_root, root)); + } + } + + None +} + impl Default for DependencyGraph { fn default() -> Self { Self::new() @@ -823,7 +1048,7 @@ mod tests { assert_eq!(graph.target_count(), 1); let jars = graph.get_target_jars("//foo:lib").unwrap(); - assert_eq!(jars[0], "/second.jar"); + assert_eq!(jars[0].classpath_path, "/second.jar"); } #[test] @@ -1020,7 +1245,7 @@ mod tests { ); let jar_list = jars.unwrap(); assert_eq!(jar_list.len(), 1); - assert_eq!(jar_list[0], "/guava.jar"); + assert_eq!(jar_list[0].classpath_path, "/guava.jar"); } #[test] @@ -1056,52 +1281,11 @@ mod tests { let jars = graph.get_target_jars("//lib:mylib").unwrap(); assert_eq!(jars.len(), 1); assert_eq!( - jars[0], "/output.jar", + jars[0].classpath_path, "/output.jar", "Source jars should be kept over compile_jars" ); } - #[test] - fn test_compile_jars_preferred_for_derived_internal_targets() { - let mut graph = DependencyGraph::new(); - - let target = TargetIdeInfo { - label: "//thrift:service".to_string(), - kind: "java_library".to_string(), - build_file: None, - java_info: Some(JavaIdeInfo { - jars: vec![JarInfo { - jar: ArtifactLocation { - is_source: false, - is_external: false, - absolute_path: Some("/libservice.jar".to_string()), - ..Default::default() - }, - ..Default::default() - }], - compile_jars: vec![ArtifactLocation { - is_source: false, - is_external: false, - absolute_path: Some("/libservice-hjar.jar".to_string()), - ..Default::default() - }], - ..Default::default() - }), - deps: vec![], - runtime_deps: Vec::new(), - exports: Vec::new(), - }; - - graph.populate_from_aspects(&[target], Path::new("/workspace")); - - let jars = graph.get_target_jars("//thrift:service").unwrap(); - assert_eq!(jars.len(), 1); - assert_eq!( - jars[0], "/libservice-hjar.jar", - "compile_jars (hjar) should be preferred for derived internal targets" - ); - } - #[test] fn test_jars_kept_when_compile_jars_empty() { let mut graph = DependencyGraph::new(); @@ -1133,13 +1317,24 @@ mod tests { let jars = graph.get_target_jars("//lib:plain").unwrap(); assert_eq!(jars.len(), 1); assert_eq!( - jars[0], "/libplain.jar", + jars[0].classpath_path, "/libplain.jar", "jars should be kept when compile_jars is empty" ); } #[test] fn test_populate_from_aspects_extracts_source_jars() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let output_jar_path = workspace.join("output.jar"); + let output_src_path = workspace.join("output-sources.jar"); + let extra_jar_path = workspace.join("extra.jar"); + std::fs::write(&output_jar_path, [0u8; 2048]).unwrap(); + std::fs::write(&output_src_path, [0u8; 2048]).unwrap(); + std::fs::write(&extra_jar_path, [0u8; 2048]).unwrap(); + let mut graph = DependencyGraph::new(); let target = TargetIdeInfo { label: "//lib:utils".to_string(), @@ -1149,18 +1344,18 @@ mod tests { jars: vec![ JarInfo { jar: ArtifactLocation { - absolute_path: Some("/output.jar".to_string()), + absolute_path: Some(output_jar_path.to_string_lossy().into_owned()), ..Default::default() }, source_jar: Some(ArtifactLocation { - absolute_path: Some("/output-sources.jar".to_string()), + absolute_path: Some(output_src_path.to_string_lossy().into_owned()), ..Default::default() }), ..Default::default() }, JarInfo { jar: ArtifactLocation { - absolute_path: Some("/extra.jar".to_string()), + absolute_path: Some(extra_jar_path.to_string_lossy().into_owned()), ..Default::default() }, source_jar: None, @@ -1174,22 +1369,44 @@ mod tests { exports: Vec::new(), }; - graph.populate_from_aspects(&[target], Path::new("/workspace")); + graph.populate_from_aspects(&[target], &workspace); + let jars = graph.get_target_jars("//lib:utils").unwrap(); + let output_jar = jars + .iter() + .find(|j| j.classpath_path == output_jar_path.to_string_lossy().as_ref()) + .expect("Expected output.jar"); assert_eq!( - graph.get_target_source_jar("//lib:utils", "/output.jar"), - Some("/output-sources.jar".to_string()), - "Expected source JAR mapping for /output.jar" + output_jar.source_path.as_deref(), + Some(output_src_path.to_str().unwrap()), + "Expected source JAR mapping for output.jar" ); + let extra_jar = jars + .iter() + .find(|j| j.classpath_path == extra_jar_path.to_string_lossy().as_ref()) + .expect("Expected extra.jar"); assert_eq!( - graph.get_target_source_jar("//lib:utils", "/extra.jar"), - None, - "Expected no source JAR for /extra.jar (no source_jar in JarInfo)" + extra_jar.source_path, None, + "Expected no source JAR for extra.jar (no source_jar in JarInfo)" ); } #[test] fn test_compile_jars_fallback_matches_source_jars_by_directory() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let jar_dir = workspace.join("external/maven/guava/33.4.0-jre"); + std::fs::create_dir_all(&jar_dir).unwrap(); + let bin_file = jar_dir.join("processed_guava-33.4.0-jre.jar"); + let src_file = jar_dir.join("guava-33.4.0-jre-sources.jar"); + std::fs::write(&bin_file, [0u8; 2048]).unwrap(); + std::fs::write(&src_file, [0u8; 2048]).unwrap(); + + let bin_path = bin_file.to_string_lossy().into_owned(); + let src_path = src_file.to_string_lossy().into_owned(); + let mut graph = DependencyGraph::new(); let target = TargetIdeInfo { label: "@maven//:guava".to_string(), @@ -1198,16 +1415,11 @@ mod tests { java_info: Some(JavaIdeInfo { jars: vec![], compile_jars: vec![ArtifactLocation { - absolute_path: Some( - "external/maven/guava/33.4.0-jre/processed_guava-33.4.0-jre.jar" - .to_string(), - ), + absolute_path: Some(bin_path.clone()), ..Default::default() }], source_jars: vec![ArtifactLocation { - absolute_path: Some( - "external/maven/guava/33.4.0-jre/guava-33.4.0-jre-sources.jar".to_string(), - ), + absolute_path: Some(src_path.clone()), ..Default::default() }], ..Default::default() @@ -1217,39 +1429,47 @@ mod tests { exports: Vec::new(), }; - graph.populate_from_aspects(&[target], Path::new("/workspace")); + graph.populate_from_aspects(&[target], &workspace); - let bin_path = "external/maven/guava/33.4.0-jre/processed_guava-33.4.0-jre.jar"; - let src_path = "external/maven/guava/33.4.0-jre/guava-33.4.0-jre-sources.jar"; + let jars = graph.get_target_jars("@maven//:guava").unwrap(); + let jar = jars + .iter() + .find(|j| j.classpath_path == bin_path) + .expect("Expected compile_jar entry"); assert_eq!( - graph.get_target_source_jar("@maven//:guava", bin_path), - Some(src_path.to_string()), + jar.source_path, + Some(src_path), "Expected source JAR matched by parent directory for java_import compile_jars fallback" ); } #[test] - fn test_get_target_source_jar_resolves_alias() { + fn test_resolved_jar_source_via_alias() { let mut graph = DependencyGraph::new(); let canonical = "@@rules_jvm_external~maven~maven//:guava"; let apparent = "@maven//:guava"; - let mut source_map = HashMap::new(); - source_map.insert("/guava.jar".to_string(), "/guava-sources.jar".to_string()); - graph - .target_source_jars - .insert(canonical.to_string(), source_map); + graph.add_target(canonical); + graph.set_target_jars( + canonical, + vec![ResolvedJar { + classpath_path: "/guava.jar".to_string(), + source_path: Some("/guava-sources.jar".to_string()), + }], + ); graph .label_aliases .insert(apparent.to_string(), canonical.to_string()); + let jars = graph.get_target_jars(apparent).unwrap(); assert_eq!( - graph.get_target_source_jar(apparent, "/guava.jar"), + jars[0].source_path, Some("/guava-sources.jar".to_string()), "Expected source JAR via alias resolution" ); + let jars2 = graph.get_target_jars(canonical).unwrap(); assert_eq!( - graph.get_target_source_jar(canonical, "/guava.jar"), + jars2[0].source_path, Some("/guava-sources.jar".to_string()), "Expected source JAR via direct canonical label" ); @@ -1381,7 +1601,10 @@ mod tests { graph.get_target_jars("//foo:lib").is_some(), "JAR data should be preserved after deps-only change" ); - assert_eq!(graph.get_target_jars("//foo:lib").unwrap()[0], "/foo.jar"); + assert_eq!( + graph.get_target_jars("//foo:lib").unwrap()[0].classpath_path, + "/foo.jar" + ); assert!(graph.has_target("//baz:new")); } @@ -1425,7 +1648,13 @@ mod tests { graph.populate_from_parsed(&parsed, &workspace_root); assert_eq!(graph.target_count(), 2); - graph.set_target_jars("//foo:old", vec!["/old.jar".to_string()]); + graph.set_target_jars( + "//foo:old", + vec![ResolvedJar { + classpath_path: "/old.jar".to_string(), + source_path: None, + }], + ); let new_parsed = ParsedBuildFile { path: PathBuf::from("/workspace/foo/BUILD"), @@ -1725,11 +1954,12 @@ mod tests { let jars = graph.get_target_jars("//app:app").unwrap(); assert_eq!(jars.len(), 1); - let resolved = &jars[0]; assert!( - resolved.contains("/execroot/external/org_example/jar/downloaded.jar"), + jars[0] + .classpath_path + .contains("/execroot/external/org_example/jar/downloaded.jar"), "Expected JAR resolved to execroot, got: {}", - resolved + jars[0].classpath_path ); } @@ -1765,7 +1995,7 @@ mod tests { let jars = graph.get_target_jars("//lib:foo").unwrap(); assert_eq!(jars.len(), 1); assert_eq!( - jars[0], jar_path, + jars[0].classpath_path, jar_path, "Non-external JAR path should remain unchanged" ); } @@ -1806,7 +2036,7 @@ mod tests { let jars = graph.get_target_jars("//app:app").unwrap(); assert_eq!(jars.len(), 1); assert_eq!( - jars[0], stale_path, + jars[0].classpath_path, stale_path, "Original path should be preserved when resolve_external_path returns None" ); } @@ -1854,13 +2084,15 @@ mod tests { let jars = graph.get_target_jars("//app:app").unwrap(); assert_eq!(jars.len(), 1); assert_eq!( - jars[0], derived_path, + jars[0].classpath_path, derived_path, "Derived artifact path should be preserved without resolve_external_path mangling" ); assert!( - jars[0].contains("bazel-out/k8-fastbuild/bin/external/maven"), - "bazel-out//bin/ prefix must be retained, got: {}", jars[0] + .classpath_path + .contains("bazel-out/k8-fastbuild/bin/external/maven"), + "bazel-out//bin/ prefix must be retained, got: {}", + jars[0].classpath_path ); } @@ -1884,6 +2116,10 @@ mod tests { "{}/bazel-out/k8-fastbuild/bin/external/maven/v1/artifact-1.0-sources.jar", workspace.display() ); + let jar_dir = std::path::Path::new(&bin_path).parent().unwrap(); + std::fs::create_dir_all(jar_dir).unwrap(); + std::fs::write(&bin_path, [0u8; 2048]).unwrap(); + std::fs::write(&src_path, [0u8; 2048]).unwrap(); let mut graph = DependencyGraph::new(); let results = vec![TargetIdeInfo { label: "//app:app".to_string(), @@ -1911,9 +2147,14 @@ mod tests { }]; graph.populate_from_aspects(&results, &workspace); - let resolved_src = graph.get_target_source_jar("//app:app", &bin_path).unwrap(); + let jars = graph.get_target_jars("//app:app").unwrap(); + let jar = jars + .iter() + .find(|j| j.classpath_path == bin_path) + .expect("Expected bin jar"); + let resolved_src = jar.source_path.as_ref().unwrap(); assert_eq!( - resolved_src, src_path, + resolved_src, &src_path, "Derived source attachment should not be resolved via resolve_external_path" ); assert!( @@ -1922,4 +2163,421 @@ mod tests { resolved_src ); } + + // --- absolutize_source_path tests --- + + #[test] + fn test_absolutize_relative_bazel_out_path() { + let ws = std::path::Path::new("/home/user/workspace"); + let result = absolutize_source_path("bazel-out/k8-fastbuild/bin/lib-src.jar", ws); + assert_eq!( + result, + "/home/user/workspace/bazel-out/k8-fastbuild/bin/lib-src.jar" + ); + } + + #[test] + fn test_absolutize_already_absolute_path() { + let ws = std::path::Path::new("/home/user/workspace"); + let result = absolutize_source_path("/absolute/path/to/src.jar", ws); + assert_eq!(result, "/absolute/path/to/src.jar"); + } + + #[test] + fn test_absolutize_non_bazel_out_relative_path() { + let ws = std::path::Path::new("/home/user/workspace"); + let result = absolutize_source_path("external/maven/src.jar", ws); + assert_eq!(result, "external/maven/src.jar"); + } + + #[test] + fn test_populate_absolutizes_derived_source_jar() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let jar_dir = workspace.join("bazel-out/k8-fastbuild/bin/lib"); + std::fs::create_dir_all(&jar_dir).unwrap(); + std::fs::write(jar_dir.join("liblib.jar"), [0u8; 2048]).unwrap(); + std::fs::write(jar_dir.join("liblib-src.jar"), [0u8; 2048]).unwrap(); + + let mut graph = DependencyGraph::new(); + let results = vec![TargetIdeInfo { + label: "//lib:lib".to_string(), + kind: "java_library".to_string(), + build_file: None, + java_info: Some(JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + relative_path: Some("lib/liblib.jar".to_string()), + root_path: Some("bazel-out/k8-fastbuild/bin".to_string()), + is_source: false, + ..Default::default() + }, + source_jar: Some(ArtifactLocation { + relative_path: Some("lib/liblib-src.jar".to_string()), + root_path: Some("bazel-out/k8-fastbuild/bin".to_string()), + is_source: false, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }), + deps: vec![], + runtime_deps: Vec::new(), + exports: Vec::new(), + }]; + graph.populate_from_aspects(&results, &workspace); + + let jars = graph.get_target_jars("//lib:lib").unwrap(); + let jar = &jars[0]; + let src = jar.source_path.as_ref().unwrap(); + assert!( + src.starts_with(workspace.to_str().unwrap()), + "Derived source path should be absolutized with workspace_root, got: {}", + src + ); + assert!( + src.ends_with("lib/liblib-src.jar"), + "Source path should end with the original relative path, got: {}", + src + ); + } + + // --- stub JAR detection tests --- + + #[test] + fn test_stub_source_jar_discarded() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let stub_jar = workspace.join("stub-sources.jar"); + std::fs::write(&stub_jar, [0u8; 283]).unwrap(); + + let classpath_jar = workspace.join("lib.jar"); + std::fs::write(&classpath_jar, [0u8; 5000]).unwrap(); + + let java_info = JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + absolute_path: Some(classpath_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }, + source_jar: Some(ArtifactLocation { + absolute_path: Some(stub_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + + let classpath_jars = vec![classpath_jar.to_string_lossy().into_owned()]; + let resolved = build_resolved_jars(&java_info, &classpath_jars, &workspace, "//lib:lib"); + + assert_eq!(resolved.len(), 1); + assert!( + resolved[0].source_path.is_none(), + "Stub source JAR (< 1KB) should be discarded, got: {:?}", + resolved[0].source_path + ); + } + + #[test] + fn test_real_source_jar_preserved() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let source_jar = workspace.join("real-sources.jar"); + std::fs::write(&source_jar, [0u8; 5000]).unwrap(); + + let classpath_jar = workspace.join("lib.jar"); + std::fs::write(&classpath_jar, [0u8; 5000]).unwrap(); + + let java_info = JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + absolute_path: Some(classpath_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }, + source_jar: Some(ArtifactLocation { + absolute_path: Some(source_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + + let classpath_jars = vec![classpath_jar.to_string_lossy().into_owned()]; + let resolved = build_resolved_jars(&java_info, &classpath_jars, &workspace, "//lib:lib"); + + assert_eq!(resolved.len(), 1); + assert_eq!( + resolved[0].source_path.as_deref(), + Some(source_jar.to_str().unwrap()), + "Real source JAR (>= 1KB) should be preserved" + ); + } + + #[test] + fn test_nonexistent_source_jar_filtered() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let classpath_jar = workspace.join("lib.jar"); + std::fs::write(&classpath_jar, [0u8; 2048]).unwrap(); + let phantom_source = "/nonexistent/path/to/sources.jar".to_string(); + + let java_info = JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + absolute_path: Some(classpath_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }, + source_jar: Some(ArtifactLocation { + absolute_path: Some(phantom_source), + is_source: false, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + + let classpath_jars = vec![classpath_jar.to_string_lossy().into_owned()]; + let resolved = build_resolved_jars(&java_info, &classpath_jars, &workspace, "//lib:lib"); + + assert_eq!(resolved.len(), 1); + assert!( + resolved[0].source_path.is_none(), + "Non-existent source path should be filtered so chain can try later strategies" + ); + } + + // --- validate_source_jar tests --- + + #[test] + fn test_validate_source_jar_stub() { + let tmp = tempfile::tempdir().unwrap(); + let stub = tmp.path().join("stub.jar"); + std::fs::write(&stub, [0u8; 283]).unwrap(); + assert_eq!(validate_source_jar(stub.to_str().unwrap()), None); + } + + #[test] + fn test_validate_source_jar_real() { + let tmp = tempfile::tempdir().unwrap(); + let real = tmp.path().join("real.jar"); + std::fs::write(&real, [0u8; 5000]).unwrap(); + assert_eq!( + validate_source_jar(real.to_str().unwrap()), + Some(real.to_str().unwrap().to_string()) + ); + } + + #[test] + fn test_validate_source_jar_nonexistent() { + let result = validate_source_jar("/nonexistent/path/sources.jar"); + assert_eq!(result, None); + } + + #[test] + fn test_stub_bypassed_maven_cache_used() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let stub_jar = workspace.join("stub-sources.jar"); + std::fs::write(&stub_jar, [0u8; 283]).unwrap(); + + let m2_path = tmp + .path() + .join(".m2/repository/com/google/guava/guava/28.2-jre"); + std::fs::create_dir_all(&m2_path).unwrap(); + let maven_source = m2_path.join("guava-28.2-jre-sources.jar"); + std::fs::write(&maven_source, [0u8; 5000]).unwrap(); + + let classpath_jar_path = workspace + .join("external/maven/v1/https/repo1.maven.org/maven2/com/google/guava/guava/28.2-jre/guava-28.2-jre.jar"); + std::fs::create_dir_all(classpath_jar_path.parent().unwrap()).unwrap(); + std::fs::write(&classpath_jar_path, [0u8; 5000]).unwrap(); + + let java_info = JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + absolute_path: Some(classpath_jar_path.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }, + source_jar: Some(ArtifactLocation { + absolute_path: Some(stub_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + + std::env::set_var("HOME", tmp.path()); + let classpath_jars = vec![classpath_jar_path.to_string_lossy().into_owned()]; + let resolved = + build_resolved_jars(&java_info, &classpath_jars, &workspace, "@maven//:guava"); + + assert_eq!(resolved.len(), 1); + assert_eq!( + resolved[0].source_path.as_deref(), + Some(maven_source.to_str().unwrap()), + "Should fall through stub to Maven cache source" + ); + } + + #[test] + fn test_stub_bypassed_no_fallback_gives_none() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let stub_jar = workspace.join("stub-sources.jar"); + std::fs::write(&stub_jar, [0u8; 283]).unwrap(); + + let classpath_jar = workspace.join("lib.jar"); + std::fs::write(&classpath_jar, [0u8; 5000]).unwrap(); + + let java_info = JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + absolute_path: Some(classpath_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }, + source_jar: Some(ArtifactLocation { + absolute_path: Some(stub_jar.to_string_lossy().into_owned()), + is_source: false, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + + std::env::set_var("HOME", tmp.path()); + let classpath_jars = vec![classpath_jar.to_string_lossy().into_owned()]; + let resolved = build_resolved_jars(&java_info, &classpath_jars, &workspace, "//lib:lib"); + + assert_eq!(resolved.len(), 1); + assert!( + resolved[0].source_path.is_none(), + "Stub with no fallback should give None, got: {:?}", + resolved[0].source_path + ); + } + + #[test] + fn test_convention_stub_bypassed_to_maven_cache() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + + let maven_jar_dir = workspace + .join("external/maven/v1/https/repo1.maven.org/maven2/com/google/guava/guava/28.2-jre"); + std::fs::create_dir_all(&maven_jar_dir).unwrap(); + let classpath_jar = maven_jar_dir.join("guava-28.2-jre.jar"); + std::fs::write(&classpath_jar, [0u8; 5000]).unwrap(); + let convention_stub = maven_jar_dir.join("guava-28.2-jre-sources.jar"); + std::fs::write(&convention_stub, [0u8; 283]).unwrap(); + + let m2_path = tmp + .path() + .join(".m2/repository/com/google/guava/guava/28.2-jre"); + std::fs::create_dir_all(&m2_path).unwrap(); + let maven_source = m2_path.join("guava-28.2-jre-sources.jar"); + std::fs::write(&maven_source, [0u8; 5000]).unwrap(); + + let java_info = JavaIdeInfo { + jars: vec![], + ..Default::default() + }; + + std::env::set_var("HOME", tmp.path()); + let classpath_jars = vec![classpath_jar.to_string_lossy().into_owned()]; + let resolved = + build_resolved_jars(&java_info, &classpath_jars, &workspace, "@maven//:guava"); + + assert_eq!(resolved.len(), 1); + assert_eq!( + resolved[0].source_path.as_deref(), + Some(maven_source.to_str().unwrap()), + "Convention stub should be filtered, falling through to Maven cache" + ); + } + + // --- Maven coordinate extraction tests --- + + #[test] + fn test_extract_maven_coordinates_standard() { + let path = "external/maven/v1/https/repo1.maven.org/maven2/com/google/guava/guava/28.2-jre/guava-28.2-jre.jar"; + let result = extract_maven_coordinates(path); + assert_eq!( + result, + Some(( + "com.google.guava".to_string(), + "guava".to_string(), + "28.2-jre".to_string() + )) + ); + } + + #[test] + fn test_extract_maven_coordinates_custom_host() { + let path = "external/maven/v1/https/artifacts.company.net/repository/maven/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar"; + let result = extract_maven_coordinates(path); + assert_eq!( + result, + Some(( + "org.slf4j".to_string(), + "slf4j-api".to_string(), + "1.7.36".to_string() + )) + ); + } + + #[test] + fn test_extract_maven_coordinates_non_maven_path() { + let path = "bazel-out/k8-fastbuild/bin/lib/liblib.jar"; + assert_eq!(extract_maven_coordinates(path), None); + } + + #[test] + fn test_probe_maven_local_cache_found() { + let tmp = tempfile::tempdir().unwrap(); + let m2_path = tmp + .path() + .join(".m2/repository/com/google/guava/guava/28.2-jre"); + std::fs::create_dir_all(&m2_path).unwrap(); + let sources_jar = m2_path.join("guava-28.2-jre-sources.jar"); + std::fs::write(&sources_jar, [0u8; 5000]).unwrap(); + + std::env::set_var("HOME", tmp.path()); + let result = probe_maven_local_cache("com.google.guava", "guava", "28.2-jre"); + assert_eq!(result, Some(sources_jar.to_string_lossy().into_owned())); + } + + #[test] + fn test_probe_maven_local_cache_not_found() { + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var("HOME", tmp.path()); + let result = probe_maven_local_cache("com.nonexistent", "artifact", "1.0"); + assert_eq!(result, None); + } } diff --git a/bazel-jdt-bridge/crates/bazel-graph/src/lib.rs b/bazel-jdt-bridge/crates/bazel-graph/src/lib.rs index fffd423..65c2ad1 100644 --- a/bazel-jdt-bridge/crates/bazel-graph/src/lib.rs +++ b/bazel-jdt-bridge/crates/bazel-graph/src/lib.rs @@ -5,4 +5,4 @@ pub use classpath::{ infer_target_kind, is_bazel_internal_label, AccessRule, ClasspathEntry, ClasspathEntryType, ComputedClasspath, JarConflict, TargetKind, }; -pub use graph::{normalize_label, DependencyGraph, GraphError}; +pub use graph::{normalize_label, DependencyGraph, GraphError, ResolvedJar}; diff --git a/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs b/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs index b6b7d17..edef750 100644 --- a/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs +++ b/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs @@ -110,7 +110,8 @@ pub extern "system" fn Java_com_bazel_jdt_BazelBridge_nativeInitialize( // Initialize stderr logger (controlled via RUST_LOG env var, default=warn). // try_init is idempotent — safe if called multiple times. let _ = env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("warn,bazel_jdt_core=info,bazel_query=info"), + env_logger::Env::default() + .default_filter_or("warn,bazel_jdt_core=info,bazel_query=info,bazel_graph=info"), ) .try_init(); diff --git a/bazel-jdt-bridge/crates/bazel-jdt-core/tests/integration.rs b/bazel-jdt-bridge/crates/bazel-jdt-core/tests/integration.rs index 63faa53..95165f4 100644 --- a/bazel-jdt-bridge/crates/bazel-jdt-core/tests/integration.rs +++ b/bazel-jdt-bridge/crates/bazel-jdt-core/tests/integration.rs @@ -1,5 +1,5 @@ use bazel_cache::BazelCache; -use bazel_graph::{ComputedClasspath, DependencyGraph, TargetKind}; +use bazel_graph::{ComputedClasspath, DependencyGraph, ResolvedJar, TargetKind}; use bazel_parser::{BuildFileParser, RuleType}; #[test] @@ -63,9 +63,27 @@ fn dependency_chain_classpath() { graph.add_dep("//app:app", "//service:service"); graph.add_dep("//service:service", "//utils:utils"); - graph.set_target_jars("//utils:utils", vec!["utils.jar".to_string()]); - graph.set_target_jars("//service:service", vec!["service.jar".to_string()]); - graph.set_target_jars("//app:app", vec!["app.jar".to_string()]); + graph.set_target_jars( + "//utils:utils", + vec![ResolvedJar { + classpath_path: "utils.jar".to_string(), + source_path: None, + }], + ); + graph.set_target_jars( + "//service:service", + vec![ResolvedJar { + classpath_path: "service.jar".to_string(), + source_path: None, + }], + ); + graph.set_target_jars( + "//app:app", + vec![ResolvedJar { + classpath_path: "app.jar".to_string(), + source_path: None, + }], + ); let classpath = ComputedClasspath::compute_for(&graph, "//app:app", TargetKind::JavaBinary, None).unwrap(); @@ -171,8 +189,20 @@ fn pipe_delimited_classpath_output() { graph.add_target("//lib:lib"); graph.add_target("//app:app"); graph.add_dep("//app:app", "//lib:lib"); - graph.set_target_jars("//lib:lib", vec!["lib.jar".to_string()]); - graph.set_target_jars("//app:app", vec!["app.jar".to_string()]); + graph.set_target_jars( + "//lib:lib", + vec![ResolvedJar { + classpath_path: "lib.jar".to_string(), + source_path: None, + }], + ); + graph.set_target_jars( + "//app:app", + vec![ResolvedJar { + classpath_path: "app.jar".to_string(), + source_path: None, + }], + ); let classpath = ComputedClasspath::compute_for(&graph, "//app:app", TargetKind::JavaBinary, None).unwrap(); diff --git a/bazel-jdt-bridge/scripts/build-for-debug.sh b/bazel-jdt-bridge/scripts/build-for-debug.sh index 5cae6e2..ddf6a91 100755 --- a/bazel-jdt-bridge/scripts/build-for-debug.sh +++ b/bazel-jdt-bridge/scripts/build-for-debug.sh @@ -184,6 +184,27 @@ if [[ "$SKIP_RUST" == false ]]; then echo "" fi +# --- Step 1b: Create placeholder native libs for other platforms --- +# bnd validates that all Bundle-NativeCode entries exist in the JAR. +# For debug builds we only have the current platform's real library, +# so create empty placeholders for the others. +echo "--- [1b] Ensuring native library placeholders for all platforms ---" +native_base="$PROJECT_ROOT/java-bridge/src/main/resources/native" +for entry in \ + "linux-x86_64/libbazel_jdt_core.so" \ + "linux-aarch64/libbazel_jdt_core.so" \ + "darwin-x86_64/libbazel_jdt_core.dylib" \ + "darwin-aarch64/libbazel_jdt_core.dylib" \ + "windows-x86_64/bazel_jdt_core.dll"; do + lib_file="$native_base/$entry" + if [[ ! -f "$lib_file" ]]; then + mkdir -p "$(dirname "$lib_file")" + touch "$lib_file" + echo " Created placeholder: native/$entry" + fi +done +echo "" + # --- Step 2: Java OSGi bundle --- step_num=$([ "$SKIP_RUST" == false ] && echo "2/3" || echo "1/2") echo "--- [$step_num] Building Java OSGi bundle ---" From 7a57b64ee00f603ba60e47b8ab41f832d34b3bca Mon Sep 17 00:00:00 2001 From: runchen0919 Date: Thu, 14 May 2026 19:19:52 +0800 Subject: [PATCH 2/2] feat: add configurable full JAR build mode for complete IDE experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sync mode setting (fast/full) that controls whether the Bazel aspect build requests full JARs or header JARs. In full mode, a new `intellij-resolve-java-full-jars` output group builds transitive runtime JARs, resolving missing imports and InlayHint errors caused by hjars stripping private members. The JAR selection logic now checks file existence before using full JARs, falling back to compile_jars (hjars) when the full JAR hasn't been built. This ensures correct classpath entries regardless of which sync mode was used. Data flow: VS Code `bazel-jdt.syncMode` setting → Java BazelBridge → JNI → Rust `build_with_aspects_sync(full_jars: bool)` → Bazel `--output_groups` flag. Co-Authored-By: Claude Opus 4.6 --- .../crates/bazel-graph/src/graph.rs | 150 ++++++++++++++++-- .../aspects/intellij_info_impl_bundled.bzl | 5 + .../crates/bazel-jdt-core/src/jni_exports.rs | 17 +- .../crates/bazel-jdt-core/src/state.rs | 2 +- .../crates/bazel-query/src/command.rs | 27 +++- .../main/java/com/bazel/jdt/BazelBridge.java | 14 +- .../com/bazel/jdt/BazelCommandHandler.java | 7 + .../vscode-extension/package.json | 10 ++ .../vscode-extension/src/commands.ts | 3 +- .../vscode-extension/src/config.ts | 2 + 10 files changed, 211 insertions(+), 26 deletions(-) diff --git a/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs b/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs index dfaf9db..0cca775 100644 --- a/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs +++ b/bazel-jdt-bridge/crates/bazel-graph/src/graph.rs @@ -190,7 +190,7 @@ impl DependencyGraph { } if let Some(ref java_info) = info.java_info { - let mut jars: Vec = java_info + let full_jars: Vec = java_info .jars .iter() .filter_map(|j| normalize_artifact_path(&j.jar, workspace_root)) @@ -202,14 +202,23 @@ impl DependencyGraph { .filter_map(|j| normalize_artifact_path(j, workspace_root)) .collect(); - let jars_all_derived_internal = !jars.is_empty() - && java_info - .jars - .iter() - .all(|j| !j.jar.is_source && !j.jar.is_external); - if jars.is_empty() || (jars_all_derived_internal && !compile_jars.is_empty()) { - jars = compile_jars; - } + let jars = if full_jars.is_empty() { + compile_jars + } else { + full_jars + .into_iter() + .enumerate() + .map(|(i, jar)| { + if std::path::Path::new(&jar).exists() { + jar + } else if i < compile_jars.len() { + compile_jars[i].clone() + } else { + jar + } + }) + .collect() + }; if !jars.is_empty() { let resolved = build_resolved_jars(java_info, &jars, workspace_root, label); @@ -1249,9 +1258,13 @@ mod tests { } #[test] - fn test_populate_from_aspects_source_jars_kept_over_compile_jars() { + fn test_populate_from_aspects_full_jars_preferred_over_compile_jars() { let mut graph = DependencyGraph::new(); + let tmp_dir = std::env::temp_dir(); + let full_jar_path = tmp_dir.join("test_full_jar_preferred.jar"); + std::fs::write(&full_jar_path, b"").unwrap(); + let target = TargetIdeInfo { label: "//lib:mylib".to_string(), kind: "java_library".to_string(), @@ -1260,7 +1273,7 @@ mod tests { jars: vec![JarInfo { jar: ArtifactLocation { is_source: true, - absolute_path: Some("/output.jar".to_string()), + absolute_path: Some(full_jar_path.to_string_lossy().into_owned()), ..Default::default() }, ..Default::default() @@ -1281,9 +1294,56 @@ mod tests { let jars = graph.get_target_jars("//lib:mylib").unwrap(); assert_eq!(jars.len(), 1); assert_eq!( - jars[0].classpath_path, "/output.jar", - "Source jars should be kept over compile_jars" + jars[0].classpath_path, + full_jar_path.to_string_lossy(), + "Full JARs should be preferred over compile_jars when they exist on disk" ); + let _ = std::fs::remove_file(&full_jar_path); + } + + #[test] + fn test_populate_from_aspects_derived_internal_jars_preferred_over_compile_jars() { + let mut graph = DependencyGraph::new(); + + let tmp_dir = std::env::temp_dir(); + let full_jar_path = tmp_dir.join("test_derived_internal.jar"); + std::fs::write(&full_jar_path, b"").unwrap(); + + let target = TargetIdeInfo { + label: "//thrift:service".to_string(), + kind: "java_library".to_string(), + build_file: None, + java_info: Some(JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + is_source: false, + is_external: false, + absolute_path: Some(full_jar_path.to_string_lossy().into_owned()), + ..Default::default() + }, + ..Default::default() + }], + compile_jars: vec![ArtifactLocation { + absolute_path: Some("/libservice-hjar.jar".to_string()), + ..Default::default() + }], + ..Default::default() + }), + deps: vec![], + runtime_deps: Vec::new(), + exports: Vec::new(), + }; + + graph.populate_from_aspects(&[target], Path::new("/workspace")); + + let jars = graph.get_target_jars("//thrift:service").unwrap(); + assert_eq!(jars.len(), 1); + assert_eq!( + jars[0].classpath_path, + full_jar_path.to_string_lossy(), + "Derived internal full JARs should be preferred over header JARs when they exist" + ); + let _ = std::fs::remove_file(&full_jar_path); } #[test] @@ -1322,6 +1382,70 @@ mod tests { ); } + #[test] + fn test_populate_from_aspects_full_jar_missing_falls_back_to_hjar() { + let mut graph = DependencyGraph::new(); + + let target = TargetIdeInfo { + label: "//lib:fallback".to_string(), + kind: "java_library".to_string(), + build_file: None, + java_info: Some(JavaIdeInfo { + jars: vec![JarInfo { + jar: ArtifactLocation { + absolute_path: Some("/nonexistent/libfallback.jar".to_string()), + ..Default::default() + }, + ..Default::default() + }], + compile_jars: vec![ArtifactLocation { + absolute_path: Some("/existing/libfallback-hjar.jar".to_string()), + ..Default::default() + }], + ..Default::default() + }), + deps: vec![], + runtime_deps: Vec::new(), + exports: Vec::new(), + }; + + graph.populate_from_aspects(&[target], Path::new("/workspace")); + + let jars = graph.get_target_jars("//lib:fallback").unwrap(); + assert_eq!(jars.len(), 1); + assert_eq!( + jars[0].classpath_path, "/existing/libfallback-hjar.jar", + "Should fall back to compile_jar (hjar) when full JAR does not exist on disk" + ); + } + + #[test] + fn test_populate_from_aspects_no_jars_no_classpath_entries() { + let mut graph = DependencyGraph::new(); + + let target = TargetIdeInfo { + label: "//lib:empty".to_string(), + kind: "java_library".to_string(), + build_file: None, + java_info: Some(JavaIdeInfo { + jars: vec![], + compile_jars: vec![], + ..Default::default() + }), + deps: vec![], + runtime_deps: Vec::new(), + exports: Vec::new(), + }; + + graph.populate_from_aspects(&[target], Path::new("/workspace")); + + let jars = graph.get_target_jars("//lib:empty"); + assert!( + jars.is_none(), + "Target with no JARs should have no classpath entries" + ); + } + #[test] fn test_populate_from_aspects_extracts_source_jars() { let tmp = tempfile::tempdir().unwrap(); diff --git a/bazel-jdt-bridge/crates/bazel-jdt-core/src/aspects/intellij_info_impl_bundled.bzl b/bazel-jdt-bridge/crates/bazel-jdt-core/src/aspects/intellij_info_impl_bundled.bzl index 5b177fc..d1c9212 100644 --- a/bazel-jdt-bridge/crates/bazel-jdt-core/src/aspects/intellij_info_impl_bundled.bzl +++ b/bazel-jdt-bridge/crates/bazel-jdt-core/src/aspects/intellij_info_impl_bundled.bzl @@ -483,6 +483,11 @@ def collect_java_info(target, ctx, semantics, ide_info, ide_info_file, output_gr if hasattr(java_info, "transitive_compile_time_jars"): update_set_in_dict(output_groups, "intellij-resolve-java-direct-deps", java_info.transitive_compile_time_jars) update_set_in_dict(output_groups, "intellij-resolve-java-direct-deps", java_info.transitive_source_jars) + + # full JARs for complete IDE experience (only built when output group is requested) + if hasattr(java_info, "transitive_runtime_jars"): + update_set_in_dict(output_groups, "intellij-resolve-java-full-jars", java_info.transitive_runtime_jars) + return True def collect_c_toolchain_info(target, ctx, semantics, ide_info, ide_info_file, output_groups): diff --git a/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs b/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs index edef750..60f1ae9 100644 --- a/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs +++ b/bazel-jdt-bridge/crates/bazel-jdt-core/src/jni_exports.rs @@ -399,12 +399,20 @@ pub extern "system" fn Java_com_bazel_jdt_BazelBridge_nativeRunAspectBuild( handle: jlong, targets: JObjectArray, build_flags: JObjectArray, + sync_mode: JString, ) -> jobjectArray { let state = match get_state(&mut env, handle) { Some(s) => s, None => return std::ptr::null_mut(), }; + let full_jars = env + .get_string(&sync_mode) + .ok() + .map(String::from) + .map(|s| s == "full") + .unwrap_or(false); + let target_vec = match parse_java_string_array(&mut env, &targets) { Some(t) => t, None => { @@ -419,12 +427,13 @@ pub extern "system" fn Java_com_bazel_jdt_BazelBridge_nativeRunAspectBuild( let build_flags_ref: Option<&[String]> = build_flags_vec.as_deref(); log::info!( - "nativeRunAspectBuild: starting batch aspect build for {} targets", - target_vec.len() + "nativeRunAspectBuild: starting batch aspect build for {} targets (full_jars={})", + target_vec.len(), + full_jars ); match state .invoker - .resolve_full_classpath_sync(&target_vec, build_flags_ref) + .resolve_full_classpath_sync(&target_vec, build_flags_ref, full_jars) { Ok(aspect_results) => { log::info!( @@ -841,7 +850,7 @@ fn run_full_resolution( log::info!("run_full_resolution for '{}'", target_label); let aspect_results = state .invoker - .resolve_full_classpath_sync(&targets, build_flags) + .resolve_full_classpath_sync(&targets, build_flags, false) .map_err(|e| { let err_str = format!("{}", e); let is_aspect_not_found = (err_str.contains("repository") diff --git a/bazel-jdt-bridge/crates/bazel-jdt-core/src/state.rs b/bazel-jdt-bridge/crates/bazel-jdt-core/src/state.rs index 7a8ac2e..78d9489 100644 --- a/bazel-jdt-bridge/crates/bazel-jdt-core/src/state.rs +++ b/bazel-jdt-bridge/crates/bazel-jdt-core/src/state.rs @@ -305,7 +305,7 @@ impl BazelJdtState { targets: &[String], ) -> Result, String> { self.invoker - .resolve_full_classpath_sync(targets, None) + .resolve_full_classpath_sync(targets, None, false) .map_err(|e| format!("Aspect build failed: {}", e)) } } diff --git a/bazel-jdt-bridge/crates/bazel-query/src/command.rs b/bazel-jdt-bridge/crates/bazel-query/src/command.rs index 68111c9..98049b3 100644 --- a/bazel-jdt-bridge/crates/bazel-query/src/command.rs +++ b/bazel-jdt-bridge/crates/bazel-query/src/command.rs @@ -141,13 +141,20 @@ impl BazelInvoker { targets: &[String], aspect_file: &str, build_flags: Option<&[String]>, + full_jars: bool, ) -> Result { let mut args = vec!["build".to_string()]; if let Some(flags) = build_flags { args.extend(flags.iter().map(|s| s.to_string())); } args.push(format!("--aspects={}", aspect_file)); - args.push("--output_groups=intellij-info-java,intellij-info-generic,intellij-resolve-java-direct-deps".to_string()); + let mut output_groups = + "intellij-info-java,intellij-info-generic,intellij-resolve-java-direct-deps" + .to_string(); + if full_jars { + output_groups.push_str(",intellij-resolve-java-full-jars"); + } + args.push(format!("--output_groups={}", output_groups)); args.push("--keep_going".to_string()); args.push("--show_result=2147483647".to_string()); args.extend(targets.iter().cloned()); @@ -170,13 +177,14 @@ impl BazelInvoker { &self, targets: &[String], build_flags: Option<&[String]>, + full_jars: bool, ) -> Result, BazelError> { if targets.is_empty() { return Ok(Vec::new()); } let aspect_output = - self.build_with_aspects_sync(targets, &self.aspect_label, build_flags)?; + self.build_with_aspects_sync(targets, &self.aspect_label, build_flags, full_jars)?; log::info!("Discovering aspect output files..."); let stderr_files = crate::output::parse_aspect_output_locations(&aspect_output); @@ -232,6 +240,7 @@ impl BazelInvoker { targets: &[String], aspect_file: &str, build_flags: Option<&[String]>, + full_jars: bool, ) -> Result { let bazel_path = self.bazel_path.clone(); let workspace_root = self.workspace_root.clone(); @@ -240,7 +249,13 @@ impl BazelInvoker { args.extend(flags.iter().map(|s| s.to_string())); } args.push(format!("--aspects={}", aspect_file)); - args.push("--output_groups=intellij-info-java,intellij-info-generic,intellij-resolve-java-direct-deps".to_string()); + let mut output_groups = + "intellij-info-java,intellij-info-generic,intellij-resolve-java-direct-deps" + .to_string(); + if full_jars { + output_groups.push_str(",intellij-resolve-java-full-jars"); + } + args.push(format!("--output_groups={}", output_groups)); args.push("--keep_going".to_string()); args.push("--show_result=2147483647".to_string()); args.extend(targets.iter().cloned()); @@ -269,20 +284,22 @@ impl BazelInvoker { &self, targets: &[String], ) -> Result, BazelError> { - self.resolve_full_classpath_with_flags(targets, None).await + self.resolve_full_classpath_with_flags(targets, None, false) + .await } pub async fn resolve_full_classpath_with_flags( &self, targets: &[String], build_flags: Option<&[String]>, + full_jars: bool, ) -> Result, BazelError> { if targets.is_empty() { return Ok(Vec::new()); } let aspect_output = self - .build_with_aspects(targets, &self.aspect_label, build_flags) + .build_with_aspects(targets, &self.aspect_label, build_flags, full_jars) .await?; let stderr_files = crate::output::parse_aspect_output_locations(&aspect_output); diff --git a/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelBridge.java b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelBridge.java index d368632..fdbc39f 100644 --- a/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelBridge.java +++ b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelBridge.java @@ -16,6 +16,7 @@ public final class BazelBridge { private String lastCacheDir; private volatile String dependencyResolutionMode = "transitive"; private volatile String dependencySourceLoadingMode = "full-project"; + private volatile String syncMode = "fast"; private volatile String[] cachedDependencyPackages = new String[0]; private static ExecutorService createExecutor() { @@ -129,8 +130,9 @@ public void populateGraph() { public String[] runAspectBuild(String[] targets, String[] buildFlags) { long h = snapshotHandle(); + String mode = this.syncMode; try { - return jniExecutor.submit(() -> nativeRunAspectBuild(h, targets, buildFlags)) + return jniExecutor.submit(() -> nativeRunAspectBuild(h, targets, buildFlags, mode)) .get(DISCOVER_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -216,6 +218,14 @@ public String getDependencySourceLoadingMode() { return this.dependencySourceLoadingMode; } + public void setSyncMode(String mode) { + this.syncMode = mode; + } + + public String getSyncMode() { + return this.syncMode; + } + public void setCachedDependencyPackages(String[] packages) { this.cachedDependencyPackages = packages != null ? packages : new String[0]; } @@ -326,7 +336,7 @@ private long snapshotHandleNullable() { private native void nativeUpdateWatchPaths(long handle, String[] watchPaths); private native String[] nativeQueryTargets(long handle, String[] scopePatterns); private native void nativePopulateGraph(long handle); - private native String[] nativeRunAspectBuild(long handle, String[] targets, String[] buildFlags); + private native String[] nativeRunAspectBuild(long handle, String[] targets, String[] buildFlags, String syncMode); private native String[] nativeComputeClasspath(long handle, String targetLabel, String[] buildFlags); private native String[] nativeComputeClasspathMerged(long handle, String[] labels); private native int nativeGetSyncState(long handle); diff --git a/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelCommandHandler.java b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelCommandHandler.java index 3d8183f..67b741b 100644 --- a/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelCommandHandler.java +++ b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelCommandHandler.java @@ -79,6 +79,13 @@ private Object handleImportProject(List arguments) { "Dependency source loading mode set to: " + loadingMode)); } + if (arguments.size() > 7 && arguments.get(7) instanceof String) { + String syncMode = (String) arguments.get(7); + bridge.setSyncMode(syncMode); + LOG.log(new Status(IStatus.INFO, "com.bazel.jdt", + "Sync mode set to: " + syncMode)); + } + String[] targets = bridge.discoverTargets(scopePatterns, buildFlags); BazelClasspathManager.refreshClasspath(); return null; diff --git a/bazel-jdt-bridge/vscode-extension/package.json b/bazel-jdt-bridge/vscode-extension/package.json index efb513b..8d4c5a7 100644 --- a/bazel-jdt-bridge/vscode-extension/package.json +++ b/bazel-jdt-bridge/vscode-extension/package.json @@ -114,6 +114,16 @@ ], "description": "How to handle inter-project dependencies when not all workspace targets are imported. 'transitive' auto-imports all dependencies; 'optional' suppresses errors for missing projects." }, + "bazel-jdt.syncMode": { + "type": "string", + "default": "fast", + "enum": ["fast", "full"], + "enumDescriptions": [ + "Use header JARs (hjars) for fast sync — best for quick iteration", + "Build full JARs for complete IDE experience — slower initial sync but no missing imports" + ], + "description": "Controls whether aspect build requests full JARs or header JARs. 'fast' uses hjars (quick sync), 'full' builds complete JARs (slower but resolves all imports and InlayHints)." + }, "bazel-jdt.dependencySourceLoading": { "type": "string", "default": "full-project", diff --git a/bazel-jdt-bridge/vscode-extension/src/commands.ts b/bazel-jdt-bridge/vscode-extension/src/commands.ts index 683f093..fd3b1f5 100644 --- a/bazel-jdt-bridge/vscode-extension/src/commands.ts +++ b/bazel-jdt-bridge/vscode-extension/src/commands.ts @@ -32,7 +32,8 @@ export function registerImportCommand(context: vscode.ExtensionContext) { progress.report({ message: 'Discovering Java targets...' }); await vscode.commands.executeCommand('java.execute.workspaceCommand', 'bazel-jdt.importProject', workspaceRoot, bazelPath, config.cacheDir, - scopePatterns, buildFlags, config.dependencyResolution, config.dependencySourceLoading); + scopePatterns, buildFlags, config.dependencyResolution, config.dependencySourceLoading, + config.syncMode); } ); vscode.window.showInformationMessage('Bazel project imported successfully'); diff --git a/bazel-jdt-bridge/vscode-extension/src/config.ts b/bazel-jdt-bridge/vscode-extension/src/config.ts index f2497dd..946481c 100644 --- a/bazel-jdt-bridge/vscode-extension/src/config.ts +++ b/bazel-jdt-bridge/vscode-extension/src/config.ts @@ -13,6 +13,7 @@ export interface BazelConfig { deriveTargets: boolean; dependencyResolution: string; dependencySourceLoading: string; + syncMode: string; } export function getConfig(): BazelConfig { @@ -30,5 +31,6 @@ export function getConfig(): BazelConfig { deriveTargets: config.get('deriveTargets', true), dependencyResolution: config.get('dependencyResolution', 'transitive'), dependencySourceLoading: config.get('dependencySourceLoading', 'full-project'), + syncMode: config.get('syncMode', 'fast'), }; }