diff --git a/bundles/com.salesforce.bazel.eclipse.core/src/com/salesforce/bazel/eclipse/core/model/discovery/projects/JavaSourceInfo.java b/bundles/com.salesforce.bazel.eclipse.core/src/com/salesforce/bazel/eclipse/core/model/discovery/projects/JavaSourceInfo.java index d138f0271..afe61d8e0 100644 --- a/bundles/com.salesforce.bazel.eclipse.core/src/com/salesforce/bazel/eclipse/core/model/discovery/projects/JavaSourceInfo.java +++ b/bundles/com.salesforce.bazel.eclipse.core/src/com/salesforce/bazel/eclipse/core/model/discovery/projects/JavaSourceInfo.java @@ -51,6 +51,8 @@ import com.salesforce.bazel.eclipse.core.model.BazelPackage; import com.salesforce.bazel.eclipse.core.model.BazelTarget; import com.salesforce.bazel.eclipse.core.model.BazelWorkspace; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Source information used by {@link JavaProjectInfo} to analyze the srcs information in order to identify @@ -58,6 +60,8 @@ */ public class JavaSourceInfo { + private static final Logger LOG = LoggerFactory.getLogger(JavaSourceInfo.class); + private static final IPath NOT_FOLLOWING_JAVA_PACKAGE_STRUCTURE = IPath.forPosix("_not_following_java_package_structure_"); private static final IPath MISSING_PACKAGE = IPath.forPosix("_missing_package_declaration_"); @@ -228,6 +232,10 @@ public void analyzeSourceDirectories(MultiStatus result, } } + // Scan BUILD directories and infer package paths for directories without direct Java files + // This must happen before split package checks to ensure complete index + scanBuildDirectoriesForPackageInfo(sourceEntriesByParentFolder, sourceEntriesBySourceRoot); + // rescue missing packages if (sourceEntriesBySourceRoot.containsKey(MISSING_PACKAGE)) { // we use the MISSING_PACKAGE when a package could not be calculated @@ -325,7 +333,9 @@ public void analyzeSourceDirectories(MultiStatus result, var entryParentLocation = bazelPackageLocation.append(potentialSourceRoot).toPath(); try { // when there are declared Java files, expect them to match - var declaredJavaFilesInFolder = entry.getValue().size(); + // Exclude synthetic entries as they don't represent actual files on disk + var declaredJavaFilesInFolder = + (int) entry.getValue().stream().filter(e -> !(e instanceof SyntheticPackageEntry)).count(); if (declaredJavaFilesInFolder > 0) { var foundJavaFiles = findJavaFilesNoneRecursive(entryParentLocation); var javaFilesInParent = foundJavaFiles.size(); @@ -367,7 +377,10 @@ public void analyzeSourceDirectories(MultiStatus result, var potentialSourceRootPath = bazelPackageLocation.append(potentialSourceRoot).toPath(); try { - var registeredFiles = ((List) potentialSourceRootAndSourceEntries.getValue()).size(); + // Exclude synthetic entries when counting registered files + var registeredFiles = (int) ((List) potentialSourceRootAndSourceEntries.getValue()).stream() + .filter(e -> !(e instanceof SyntheticPackageEntry)) + .count(); var foundJavaFiles = findJavaFilesRecursive(potentialSourceRootPath); var foundJavaFilesInSourceRoot = foundJavaFiles.size(); if ((registeredFiles != foundJavaFilesInSourceRoot) @@ -397,6 +410,21 @@ public void analyzeSourceDirectories(MultiStatus result, (List) sourceEntriesBySourceRoot.remove(NOT_FOLLOWING_JAVA_PACKAGE_STRUCTURE); } + // Remove source roots that only contain synthetic entries + // These are virtual entries for BUILD directories without actual Java files + // and should not appear in the final Eclipse project configuration + sourceEntriesBySourceRoot.entrySet().removeIf(entry -> { + if (!(entry.getValue() instanceof List entries)) { + return false; // Keep GlobEntry + } + // Remove if all entries are synthetic + boolean allSynthetic = entries.stream().allMatch(e -> e instanceof SyntheticPackageEntry); + if (allSynthetic) { + LOG.debug("Removing source root '{}' as it only contains synthetic entries", entry.getKey()); + } + return allSynthetic; + }); + // create source directories sourceDirectoriesWithFilesOrGlobs = sourceEntriesBySourceRoot; @@ -477,6 +505,273 @@ private IPath detectPackagePath(JavaSourceEntry fileEntry) { return packagePath; } + /** + * Scans BUILD file directories and infers package paths for directories without direct Java files. + *

+ * This method finds all BUILD files in the workspace, checks if their directories lack direct Java files, and + * creates synthetic entries to establish proper package structure. + *

+ * + * @param sourceEntriesByParentFolder + * mapping from directories to file entry lists + * @param sourceEntriesBySourceRoot + * mapping from source roots to entries + */ + private void scanBuildDirectoriesForPackageInfo( + Map> sourceEntriesByParentFolder, + Map sourceEntriesBySourceRoot) throws CoreException { + + // 1. Find all BUILD files + List buildDirectories = findBuildFileDirectories(); + LOG.debug("Found {} BUILD files in workspace", buildDirectories.size()); + + // 2. Check each BUILD directory + for (IPath buildDir : buildDirectories) { + // 2.1 Skip if directory already has entries (has Java files) + if (sourceEntriesByParentFolder.containsKey(buildDir)) { + continue; + } + + // 2.2 Check if directory has direct Java files + Path buildDirPath = bazelPackageLocation.append(buildDir).toPath(); + try { + List javaFiles = findJavaFilesNoneRecursive(buildDirPath); + if (!javaFiles.isEmpty()) { + continue; // Has files but not processed, might be in glob exclude + } + } catch (IOException e) { + LOG.debug("Failed to scan BUILD directory '{}': {}", buildDir, e.getMessage()); + continue; + } + + // 2.3 Infer package path + IPath inferredPackagePath = inferPackagePathForBuildDirectory(buildDir); + if (inferredPackagePath == null) { + LOG.debug("Unable to infer package for BUILD directory '{}', skipping", buildDir); + continue; + } + + // 2.4 Create synthetic entry and add to indexes + createSyntheticEntryForBuildDirectory( + buildDir, + inferredPackagePath, + sourceEntriesByParentFolder, + sourceEntriesBySourceRoot); + } + } + + /** + * Finds all directories containing BUILD or BUILD.bazel files. + * + * @return list of BUILD file directories (relative to bazelPackageLocation) + */ + private List findBuildFileDirectories() throws CoreException { + List buildDirs = new ArrayList<>(); + Path packageRoot = bazelPackageLocation.toPath(); + + try { + walkFileTree(packageRoot, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + String fileName = file.getFileName().toString(); + if ("BUILD".equals(fileName) || "BUILD.bazel".equals(fileName)) { + Path relativeDir = packageRoot.relativize(file.getParent()); + buildDirs.add(IPath.fromPath(relativeDir)); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + throw new CoreException(Status.error("Error scanning for BUILD files", e)); + } + + return buildDirs; + } + + /** + * Infers package path for a BUILD directory using two strategies: 1. Path pattern matching (primary) 2. Child + * directory reverse inference (fallback) + * + * @param buildDirectory + * BUILD file directory relative path + * @return inferred package path, or null if unable to infer + */ + private IPath inferPackagePathForBuildDirectory(IPath buildDirectory) { + // Strategy A: Path pattern inference + IPath pathBasedPackage = inferFromPathPattern(buildDirectory); + if (pathBasedPackage != null) { + LOG.debug( + "Inferred package '{}' for BUILD directory '{}' using path pattern", + pathBasedPackage, + buildDirectory); + return pathBasedPackage; + } + + // Strategy B: Child directory reverse inference + IPath childBasedPackage = inferFromChildDirectories(buildDirectory); + if (childBasedPackage != null) { + LOG.debug( + "Inferred package '{}' for BUILD directory '{}' from child directories", + childBasedPackage, + buildDirectory); + return childBasedPackage; + } + + return null; // Unable to infer + } + + /** + * Infers package path based on standard Java source root patterns. + * + * @param buildDirectory + * the BUILD directory path + * @return inferred package path, or null if no pattern matches + */ + private IPath inferFromPathPattern(IPath buildDirectory) { + String[] sourceRootPatterns = { + "src/main/java", + "src/test/java", + "java", + "src/main/resources", + "src/test/resources", + "src" }; + + String pathString = buildDirectory.toString(); + + for (String pattern : sourceRootPatterns) { + int index = pathString.indexOf(pattern); + if (index >= 0) { + int sourceRootEnd = index + pattern.length(); + + if (sourceRootEnd < pathString.length()) { + // Has remaining path, that's the package path + String packagePath = pathString.substring(sourceRootEnd); + packagePath = packagePath.replaceAll("^/+|/+$", ""); + + if (!packagePath.isEmpty()) { + return IPath.forPosix(packagePath); + } + } + + // Exactly at source root position + return IPath.EMPTY; + } + } + + return null; + } + + /** + * Infers package path by reverse engineering from child directory's known package. + * + * @param buildDirectory + * the BUILD directory path + * @return inferred package path, or null if unable to infer + */ + private IPath inferFromChildDirectories(IPath buildDirectory) { + // Look for cached package paths in child directories + for (Map.Entry entry : detectedPackagePathsByFileEntryPathParent.entrySet()) { + IPath childDir = entry.getKey(); + IPath childPackage = entry.getValue(); + + // Check if it's a direct child directory + if (buildDirectory.isPrefixOf(childDir) && childDir.segmentCount() == buildDirectory.segmentCount() + 1) { + + // Parent package = child package with last segment removed + if (childPackage.segmentCount() > 0) { + return childPackage.removeLastSegments(1); + } + } + } + + return null; + } + + /** + * Creates a synthetic entry for BUILD directory and adds it to indexes. + * + * @param buildDirectory + * BUILD directory path + * @param inferredPackagePath + * inferred package path + * @param sourceEntriesByParentFolder + * directory index + * @param sourceEntriesBySourceRoot + * source root index + */ + @SuppressWarnings("unchecked") + private void createSyntheticEntryForBuildDirectory(IPath buildDirectory, IPath inferredPackagePath, + Map> sourceEntriesByParentFolder, + Map sourceEntriesBySourceRoot) { + + // 1. Calculate source root directory + IPath sourceRoot = inferSourceRootFromBuildDirectory(buildDirectory); + + // 2. Create synthetic entry + SyntheticPackageEntry syntheticEntry = + new SyntheticPackageEntry(buildDirectory, inferredPackagePath, sourceRoot); + + // 3. Cache to package path mapping + detectedPackagePathsByFileEntryPathParent.put(buildDirectory, inferredPackagePath); + + // 4. Add to parent directory index + sourceEntriesByParentFolder.putIfAbsent(buildDirectory, new ArrayList<>()); + sourceEntriesByParentFolder.get(buildDirectory).add(syntheticEntry); + + // 5. Add to source root index + if (!sourceEntriesBySourceRoot.containsKey(sourceRoot)) { + List list = new ArrayList<>(); + list.add(syntheticEntry); + sourceEntriesBySourceRoot.put(sourceRoot, list); + } else { + Object existing = sourceEntriesBySourceRoot.get(sourceRoot); + if (existing instanceof List) { + List list = (List) existing; + list.add(syntheticEntry); + } + } + + LOG.debug( + "Created synthetic entry for BUILD directory '{}' with package '{}' and source root '{}'", + buildDirectory, + inferredPackagePath, + sourceRoot); + } + + /** + * Infers source root directory from BUILD directory path. + * + * @param buildDirectory + * the BUILD directory path + * @return inferred source root path + */ + private IPath inferSourceRootFromBuildDirectory(IPath buildDirectory) { + String[] sourceRootPatterns = { + "src/main/java", + "src/test/java", + "java", + "src/main/resources", + "src/test/resources", + "src" }; + + String pathString = buildDirectory.toString(); + + for (String pattern : sourceRootPatterns) { + int index = pathString.indexOf(pattern); + if (index >= 0) { + int sourceRootEnd = index + pattern.length(); + return IPath.forPosix(pathString.substring(0, sourceRootEnd)); + } + } + + // Cannot identify, use first segment as source root + if (buildDirectory.segmentCount() > 0) { + return IPath.forPosix(buildDirectory.segment(0)); + } + + return buildDirectory; + } + /** * Extract the source jar (typically found in the bazel-bin directory of the package) into a directory for * consumption as source folder in an Eclipse project. @@ -759,7 +1054,9 @@ private String readPackageName(JavaSourceEntry fileEntry) { private void reportSplitPackagesProblem(MultiStatus result, Path rootDirectory, List declaredEntries, List foundJavaFiles) { + // Filter out synthetic entries as they don't represent actual files on disk SortedSet registeredFilesSet = declaredEntries.stream() + .filter(o -> !(o instanceof SyntheticPackageEntry)) .map(o -> ((JavaSourceEntry) o).getLocation().toPath()) .collect(toCollection(TreeSet::new)); SortedSet foundFilesSet = new TreeSet<>(foundJavaFiles); @@ -791,8 +1088,10 @@ public boolean shouldDisableOptionalCompileProblemsForSourceDirectory(IPath sour } if (fileOrGlob instanceof List listOfEntries) { // the case is save assuming no programming mistakes in this class + // filter out synthetic entries as they don't have settings in the srcs map return listOfEntries.stream() .map(JavaSourceEntry.class::cast) + .filter(e -> !(e instanceof SyntheticPackageEntry)) .map(this::getEntrySettings) .anyMatch(EntrySettings::nowarn); } @@ -800,7 +1099,47 @@ public boolean shouldDisableOptionalCompileProblemsForSourceDirectory(IPath sour } public boolean shouldDisableOptionalCompileProblemsForSourceFilesWithoutCommonRoot() { - return getSourceFilesWithoutCommonRoot().stream().anyMatch(e -> getEntrySettings(e).nowarn()); + return getSourceFilesWithoutCommonRoot().stream() + .filter(e -> !(e instanceof SyntheticPackageEntry)) + .anyMatch(e -> getEntrySettings(e).nowarn()); } + + /** + * Represents a synthetic package entry for BUILD directories without direct Java files. + *

+ * This virtual entry is used to establish proper package path information for directories that contain BUILD files + * but no direct Java sources, enabling correct source root detection and preventing false positive split package + * warnings. + *

+ */ + private static class SyntheticPackageEntry extends JavaSourceEntry { + private final IPath inferredSourceRoot; + + public SyntheticPackageEntry(IPath buildDirectory, IPath packagePath, IPath sourceRoot) { + // Create virtual file path for marker purposes + super(buildDirectory.append("_synthetic_package_.java"), IPath.EMPTY); + this.detectedPackagePath = packagePath; + this.inferredSourceRoot = sourceRoot; + } + + @Override + public IPath getPotentialSourceDirectoryRoot() { + return inferredSourceRoot; + } + + @Override + public boolean isExternalOrGenerated() { + return false; // Treat as normal source + } + + @Override + public String toString() { + return format( + "SyntheticPackageEntry[dir=%s, package=%s, sourceRoot=%s]", + getPathParent(), + detectedPackagePath, + inferredSourceRoot); + } + } } diff --git a/bundles/testdata/workspaces/001/.bazelrc b/bundles/testdata/workspaces/001/.bazelrc new file mode 100644 index 000000000..711b1cc4a --- /dev/null +++ b/bundles/testdata/workspaces/001/.bazelrc @@ -0,0 +1,2 @@ +# bazelmod is enabled by default in bazel 7, but we are not ready yet +common --noenable_bzlmod diff --git a/bundles/testdata/workspaces/001/MODULE.bazel b/bundles/testdata/workspaces/001/MODULE.bazel new file mode 100644 index 000000000..00bb18361 --- /dev/null +++ b/bundles/testdata/workspaces/001/MODULE.bazel @@ -0,0 +1,6 @@ +############################################################################### +# Bazel now uses Bzlmod by default to manage external dependencies. +# Please consider migrating your external dependencies from WORKSPACE to MODULE.bazel. +# +# For more details, please check https://github.com/bazelbuild/bazel/issues/18958 +############################################################################### diff --git a/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/BUILD b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/BUILD new file mode 100644 index 000000000..b8fc43c98 --- /dev/null +++ b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/BUILD @@ -0,0 +1,13 @@ +load("@rules_java//java:defs.bzl", "java_library") + +java_library( + name = "async_commit_example", + srcs = glob([ + "*.java", + "async_commit_example/*.java", + "async_commit_example/annotations/*.java", + ]), + deps = [ + ], +) + diff --git a/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/ExampleDaemon.java b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/ExampleDaemon.java new file mode 100644 index 000000000..31b6d6efa --- /dev/null +++ b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/ExampleDaemon.java @@ -0,0 +1,9 @@ +package com.project.server.examples.async_commit_example; + +import com.project.server.examples.async_commit_example.annotations.RandomMessageGenSub0; +import com.project.server.examples.async_commit_example.annotations.RandomMessageGenSub1; + + +public class ExampleDaemon { + +} diff --git a/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/annotations/RandomMessageGenSub0.java b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/annotations/RandomMessageGenSub0.java new file mode 100644 index 000000000..760cff1bb --- /dev/null +++ b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/annotations/RandomMessageGenSub0.java @@ -0,0 +1,13 @@ +package com.project.server.examples.async_commit_example.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; + +@Retention(RetentionPolicy.RUNTIME) +@Target({METHOD, PARAMETER}) +public @interface RandomMessageGenSub0 { +} diff --git a/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/annotations/RandomMessageGenSub1.java b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/annotations/RandomMessageGenSub1.java new file mode 100644 index 000000000..3ff0d8a14 --- /dev/null +++ b/bundles/testdata/workspaces/001/module4/java/com/project/server/examples/async_commit_example/annotations/RandomMessageGenSub1.java @@ -0,0 +1,13 @@ +package com.project.server.examples.async_commit_example.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; + +@Retention(RetentionPolicy.RUNTIME) +@Target({METHOD, PARAMETER}) +public @interface RandomMessageGenSub1 { +}