Skip to content

Commit 44e24b8

Browse files
committed
feat: add build-tool classpath resolution layer
1 parent b64c679 commit 44e24b8

35 files changed

Lines changed: 2717 additions & 1 deletion

pom.xml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929

3030
<properties>
3131
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
32-
<revision>0.3.7</revision>
32+
<revision>0.3.8</revision>
3333
<maven.compiler.release>17</maven.compiler.release>
3434
<picocli.version>4.7.7</picocli.version>
3535
<javaparser.version>3.27.1</javaparser.version>
3636
<bouncycastle.version>1.78.1</bouncycastle.version>
3737
<junit.version>5.11.4</junit.version>
38+
<maven.invoker.version>3.3.0</maven.invoker.version>
39+
<gradle.tooling.api.version>7.3-20210825160000+0000</gradle.tooling.api.version>
3840
<maven.compiler.plugin.version>3.14.0</maven.compiler.plugin.version>
3941
<maven.surefire.plugin.version>3.5.2</maven.surefire.plugin.version>
4042
<maven.jar.plugin.version>3.4.2</maven.jar.plugin.version>
@@ -62,6 +64,18 @@
6264
<version>${javaparser.version}</version>
6365
</dependency>
6466

67+
<dependency>
68+
<groupId>org.apache.maven.shared</groupId>
69+
<artifactId>maven-invoker</artifactId>
70+
<version>${maven.invoker.version}</version>
71+
</dependency>
72+
73+
<dependency>
74+
<groupId>org.gradle</groupId>
75+
<artifactId>gradle-tooling-api</artifactId>
76+
<version>${gradle.tooling.api.version}</version>
77+
</dependency>
78+
6579
<dependency>
6680
<groupId>org.junit.jupiter</groupId>
6781
<artifactId>junit-jupiter</artifactId>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.jaipilot.cli.classpath;
2+
3+
import java.nio.file.Files;
4+
import java.nio.file.Path;
5+
import java.util.Locale;
6+
7+
final class BuildExecutableResolver {
8+
9+
private BuildExecutableResolver() {
10+
}
11+
12+
static Path resolveMavenExecutable(Path projectRoot, Path moduleRoot) {
13+
boolean windows = isWindows();
14+
String wrapperName = windows ? "mvnw.cmd" : "mvnw";
15+
Path wrapper = findWrapper(moduleRoot, projectRoot, wrapperName, true);
16+
if (wrapper != null) {
17+
return wrapper;
18+
}
19+
return Path.of(windows ? "mvn.cmd" : "mvn");
20+
}
21+
22+
static Path resolveGradleExecutable(Path projectRoot, Path moduleRoot) {
23+
boolean windows = isWindows();
24+
String wrapperName = windows ? "gradlew.bat" : "gradlew";
25+
Path wrapper = findWrapper(moduleRoot, projectRoot, wrapperName, false);
26+
if (wrapper != null) {
27+
return wrapper;
28+
}
29+
return Path.of(windows ? "gradle.bat" : "gradle");
30+
}
31+
32+
private static Path findWrapper(Path moduleRoot, Path projectRoot, String wrapperName, boolean requireMavenWrapperProps) {
33+
Path normalizedProjectRoot = projectRoot.toAbsolutePath().normalize();
34+
Path current = moduleRoot.toAbsolutePath().normalize();
35+
while (current != null && current.startsWith(normalizedProjectRoot)) {
36+
Path wrapper = current.resolve(wrapperName);
37+
if (Files.isRegularFile(wrapper)) {
38+
if (!requireMavenWrapperProps || Files.isRegularFile(current.resolve(".mvn/wrapper/maven-wrapper.properties"))) {
39+
return wrapper.toAbsolutePath().normalize();
40+
}
41+
}
42+
if (current.equals(normalizedProjectRoot)) {
43+
break;
44+
}
45+
current = current.getParent();
46+
}
47+
return null;
48+
}
49+
50+
private static boolean isWindows() {
51+
return System.getProperty("os.name", "")
52+
.toLowerCase(Locale.ROOT)
53+
.contains("win");
54+
}
55+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.jaipilot.cli.classpath;
2+
3+
import java.nio.file.Path;
4+
import java.util.Optional;
5+
import java.util.Set;
6+
import java.util.concurrent.ConcurrentHashMap;
7+
8+
public final class BuildToolClassResolutionService {
9+
10+
private static final System.Logger LOGGER = System.getLogger(BuildToolClassResolutionService.class.getName());
11+
12+
private final BuildToolClasspathResolver classpathResolver;
13+
private final ClassLocator classLocator;
14+
private final Set<String> compileFallbackAttemptedFingerprints;
15+
16+
public BuildToolClassResolutionService() {
17+
this(new BuildToolClasspathResolver(), new ClasspathClassLocator(), ConcurrentHashMap.newKeySet());
18+
}
19+
20+
BuildToolClassResolutionService(
21+
BuildToolClasspathResolver classpathResolver,
22+
ClassLocator classLocator,
23+
Set<String> compileFallbackAttemptedFingerprints
24+
) {
25+
this.classpathResolver = classpathResolver;
26+
this.classLocator = classLocator;
27+
this.compileFallbackAttemptedFingerprints = compileFallbackAttemptedFingerprints;
28+
}
29+
30+
public ResolvedClasspath resolveClasspath(Path projectRoot, Path moduleRoot, ResolutionOptions options) {
31+
return classpathResolver.resolveTestClasspath(projectRoot, moduleRoot, options);
32+
}
33+
34+
public ClassResolutionResult locate(
35+
String fqcnOrImport,
36+
Path projectRoot,
37+
Path moduleRoot,
38+
ResolutionOptions options
39+
) {
40+
ResolutionOptions normalizedOptions = options == null ? ResolutionOptions.defaults() : options;
41+
String normalizedFqcn = ClassNameParser.normalizeFqcn(fqcnOrImport);
42+
43+
ResolvedClasspath classpath = classpathResolver.resolveTestClasspath(projectRoot, moduleRoot, normalizedOptions);
44+
ClassResolutionResult result = classLocator.locate(normalizedFqcn, classpath);
45+
if (result.kind() != LocationKind.NOT_FOUND) {
46+
return result;
47+
}
48+
49+
if (!normalizedOptions.allowCompileFallback()) {
50+
LOGGER.log(System.Logger.Level.DEBUG, "compile fallback skipped for {0}", classpath.moduleRoot());
51+
return result;
52+
}
53+
54+
if (!compileFallbackAttemptedFingerprints.add(classpath.fingerprint())) {
55+
LOGGER.log(System.Logger.Level.DEBUG,
56+
"compile fallback skipped because it already ran for fingerprint {0}",
57+
classpath.fingerprint());
58+
return result;
59+
}
60+
61+
LOGGER.log(System.Logger.Level.INFO, "compile fallback triggered after class miss for module {0}", classpath.moduleRoot());
62+
ResolvedClasspath fallbackClasspath = classpathResolver.resolveTestClasspath(
63+
projectRoot,
64+
moduleRoot,
65+
normalizedOptions,
66+
true
67+
);
68+
return classLocator.locate(normalizedFqcn, fallbackClasspath);
69+
}
70+
71+
public ClassResolutionResult locateOrThrow(
72+
String fqcnOrImport,
73+
Path projectRoot,
74+
Path moduleRoot,
75+
ResolutionOptions options
76+
) {
77+
ClassResolutionResult result = locate(fqcnOrImport, projectRoot, moduleRoot, options);
78+
if (result.kind() != LocationKind.NOT_FOUND) {
79+
return result;
80+
}
81+
throw new ClasspathResolutionException(new ResolutionFailure(
82+
ResolutionFailureCategory.CLASS_NOT_FOUND_ON_RESOLVED_CLASSPATH,
83+
null,
84+
moduleRoot,
85+
"locate class " + fqcnOrImport,
86+
"Class was not found on resolved classpath."
87+
));
88+
}
89+
90+
public Optional<ResolvedSource> resolveSource(
91+
ClassResolutionResult classResult,
92+
Path projectRoot,
93+
Path moduleRoot,
94+
ResolutionOptions options
95+
) {
96+
return new DefaultSourceResolver(projectRoot, moduleRoot).resolveSource(classResult, options);
97+
}
98+
99+
public ResolvedSource resolveSourceOrThrow(
100+
ClassResolutionResult classResult,
101+
Path projectRoot,
102+
Path moduleRoot,
103+
ResolutionOptions options
104+
) {
105+
return resolveSource(classResult, projectRoot, moduleRoot, options)
106+
.orElseThrow(() -> new ClasspathResolutionException(new ResolutionFailure(
107+
ResolutionFailureCategory.SOURCE_NOT_AVAILABLE,
108+
null,
109+
moduleRoot,
110+
"resolve source for " + (classResult == null ? "<null>" : classResult.fqcn()),
111+
"Source was not available."
112+
)));
113+
}
114+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.jaipilot.cli.classpath;
2+
3+
import java.nio.file.Path;
4+
5+
public final class BuildToolClasspathResolver implements ClasspathResolver {
6+
7+
private static final System.Logger LOGGER = System.getLogger(BuildToolClasspathResolver.class.getName());
8+
9+
private final BuildToolDetector buildToolDetector;
10+
private final CompileCapableClasspathResolver mavenResolver;
11+
private final CompileCapableClasspathResolver gradleResolver;
12+
13+
public BuildToolClasspathResolver() {
14+
this(new BuildToolDetector(), new MavenClasspathResolver(), new GradleClasspathResolver());
15+
}
16+
17+
BuildToolClasspathResolver(
18+
BuildToolDetector buildToolDetector,
19+
CompileCapableClasspathResolver mavenResolver,
20+
CompileCapableClasspathResolver gradleResolver
21+
) {
22+
this.buildToolDetector = buildToolDetector;
23+
this.mavenResolver = mavenResolver;
24+
this.gradleResolver = gradleResolver;
25+
}
26+
27+
@Override
28+
public ResolvedClasspath resolveTestClasspath(Path projectRoot, Path moduleRoot, ResolutionOptions options) {
29+
return resolveTestClasspath(projectRoot, moduleRoot, options, false);
30+
}
31+
32+
ResolvedClasspath resolveTestClasspath(Path projectRoot, Path moduleRoot, ResolutionOptions options, boolean forceCompile) {
33+
BuildToolType buildTool = buildToolDetector.detectBuildTool(projectRoot, moduleRoot);
34+
LOGGER.log(System.Logger.Level.DEBUG, "build tool detected: {0}", buildTool);
35+
return switch (buildTool) {
36+
case MAVEN -> mavenResolver.resolveTestClasspath(projectRoot, moduleRoot, options, forceCompile);
37+
case GRADLE -> gradleResolver.resolveTestClasspath(projectRoot, moduleRoot, options, forceCompile);
38+
};
39+
}
40+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.jaipilot.cli.classpath;
2+
3+
import java.nio.file.Files;
4+
import java.nio.file.Path;
5+
import java.util.List;
6+
7+
public final class BuildToolDetector {
8+
9+
private static final List<String> GRADLE_MARKERS = List.of(
10+
"settings.gradle",
11+
"settings.gradle.kts",
12+
"build.gradle",
13+
"build.gradle.kts"
14+
);
15+
16+
public BuildToolType detectBuildTool(Path projectRoot) {
17+
return detectBuildTool(projectRoot, projectRoot);
18+
}
19+
20+
public BuildToolType detectBuildTool(Path projectRoot, Path moduleRoot) {
21+
Path normalizedProjectRoot = normalizeDirectory(projectRoot, "projectRoot");
22+
Path normalizedModuleRoot = normalizeDirectory(moduleRoot, "moduleRoot");
23+
if (!normalizedModuleRoot.startsWith(normalizedProjectRoot)) {
24+
throw new ClasspathResolutionException(new ResolutionFailure(
25+
ResolutionFailureCategory.UNSUPPORTED_BUILD_TOOL,
26+
null,
27+
normalizedModuleRoot,
28+
"build-tool-detection",
29+
"Module root must be inside project root."
30+
));
31+
}
32+
33+
boolean moduleHasPom = Files.isRegularFile(normalizedModuleRoot.resolve("pom.xml"));
34+
boolean moduleHasGradle = hasGradleMarker(normalizedModuleRoot);
35+
if (moduleHasPom && moduleHasGradle) {
36+
throw new ClasspathResolutionException(new ResolutionFailure(
37+
ResolutionFailureCategory.UNSUPPORTED_BUILD_TOOL,
38+
null,
39+
normalizedModuleRoot,
40+
"build-tool-detection",
41+
"Both Maven and Gradle build files exist at module root."
42+
));
43+
}
44+
if (moduleHasPom) {
45+
return BuildToolType.MAVEN;
46+
}
47+
if (moduleHasGradle) {
48+
return BuildToolType.GRADLE;
49+
}
50+
51+
boolean mavenDetected = hasPomInAncestors(normalizedModuleRoot, normalizedProjectRoot);
52+
boolean gradleDetected = hasGradleInAncestors(normalizedModuleRoot, normalizedProjectRoot);
53+
54+
if (mavenDetected && gradleDetected) {
55+
throw new ClasspathResolutionException(new ResolutionFailure(
56+
ResolutionFailureCategory.UNSUPPORTED_BUILD_TOOL,
57+
null,
58+
normalizedModuleRoot,
59+
"build-tool-detection",
60+
"Both Maven and Gradle markers exist in ancestor hierarchy; pick a module root with one tool."
61+
));
62+
}
63+
if (mavenDetected) {
64+
return BuildToolType.MAVEN;
65+
}
66+
if (gradleDetected) {
67+
return BuildToolType.GRADLE;
68+
}
69+
70+
throw new ClasspathResolutionException(new ResolutionFailure(
71+
ResolutionFailureCategory.UNSUPPORTED_BUILD_TOOL,
72+
null,
73+
normalizedModuleRoot,
74+
"build-tool-detection",
75+
"No supported build files were found."
76+
));
77+
}
78+
79+
private static Path normalizeDirectory(Path path, String label) {
80+
if (path == null) {
81+
throw new IllegalArgumentException(label + " must not be null");
82+
}
83+
return path.toAbsolutePath().normalize();
84+
}
85+
86+
private boolean hasPomInAncestors(Path moduleRoot, Path projectRoot) {
87+
Path current = moduleRoot;
88+
while (current != null && current.startsWith(projectRoot)) {
89+
if (Files.isRegularFile(current.resolve("pom.xml"))) {
90+
return true;
91+
}
92+
if (current.equals(projectRoot)) {
93+
break;
94+
}
95+
current = current.getParent();
96+
}
97+
return false;
98+
}
99+
100+
private boolean hasGradleInAncestors(Path moduleRoot, Path projectRoot) {
101+
Path current = moduleRoot;
102+
while (current != null && current.startsWith(projectRoot)) {
103+
if (hasGradleMarker(current)) {
104+
return true;
105+
}
106+
if (current.equals(projectRoot)) {
107+
break;
108+
}
109+
current = current.getParent();
110+
}
111+
return false;
112+
}
113+
114+
private boolean hasGradleMarker(Path directory) {
115+
return GRADLE_MARKERS.stream()
116+
.map(directory::resolve)
117+
.anyMatch(Files::isRegularFile);
118+
}
119+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.jaipilot.cli.classpath;
2+
3+
public enum BuildToolType {
4+
MAVEN,
5+
GRADLE
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.jaipilot.cli.classpath;
2+
3+
public interface ClassLocator {
4+
5+
ClassResolutionResult locate(String fqcn, ResolvedClasspath classpath);
6+
}

0 commit comments

Comments
 (0)