From 3f8453c1f1e81c9582eda5fb69ec7170c1b3e847 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Sun, 12 Jul 2026 03:19:28 +0200 Subject: [PATCH] feat: resolve classpath from pom.xml and build.gradle via --deps or //DEPS Treat Maven and Gradle build files as dependency references. When passed via --deps or //DEPS, JBang shells out to the build tool, caches the resolved classpath, and adds entries to the project classpath. Supports explicit paths (--deps pom.xml) and upward search from the script location (--deps ^pom.xml). This is an experimental feature. --- docs/modules/ROOT/pages/running.adoc | 25 +++ .../dependencies/BuildSystemClassPaths.java | 203 ++++++++++++++++++ .../java/dev/jbang/source/ProjectBuilder.java | 25 ++- .../TestBuildSystemClassPaths.java | 84 ++++++++ 4 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 src/main/java/dev/jbang/dependencies/BuildSystemClassPaths.java create mode 100644 src/test/java/dev/jbang/dependencies/TestBuildSystemClassPaths.java diff --git a/docs/modules/ROOT/pages/running.adoc b/docs/modules/ROOT/pages/running.adoc index 965831e859..e6ff9fbe75 100644 --- a/docs/modules/ROOT/pages/running.adoc +++ b/docs/modules/ROOT/pages/running.adoc @@ -91,6 +91,31 @@ Since Java 9 https://docs.oracle.com/javase/9/tools/java.htm#GUID-3B1CE181-CD30- For Java 8 and if you want to set explicitly only for `jbang` you can also add flags by setting `JBANG_JAVA_OPTIONS`. +=== Classpath from build files (EXPERIMENTAL) + +You can use a Maven or Gradle build file as a dependency. JBang asks that build tool for its runtime classpath and adds the returned entries. + +In source: + +[source, java] +---- +//DEPS pom.xml +//DEPS build.gradle +//DEPS ^pom.xml +---- + +Or from the command line: + +[source, bash] +---- +jbang run --deps pom.xml app.java +jbang run --deps ^pom.xml src/main/java/app.java +---- + +The `^` form searches upward from the script location, so `^pom.xml` means "use the nearest parent `pom.xml`". + +NOTE: Maven and Gradle build files can execute code. Only use this with build files you trust. + == Preview The example above can alternatively also be written with `PREVIEW` instead of `--enable-preview`, like this: diff --git a/src/main/java/dev/jbang/dependencies/BuildSystemClassPaths.java b/src/main/java/dev/jbang/dependencies/BuildSystemClassPaths.java new file mode 100644 index 0000000000..9c03b2f34c --- /dev/null +++ b/src/main/java/dev/jbang/dependencies/BuildSystemClassPaths.java @@ -0,0 +1,203 @@ +package dev.jbang.dependencies; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import dev.jbang.Cache; +import dev.jbang.Settings; +import dev.jbang.cli.BaseCommand; +import dev.jbang.cli.ExitException; +import dev.jbang.util.Util; + +public class BuildSystemClassPaths { + + private static final List BUILD_FILES = Arrays.asList("pom.xml", "build.gradle", "build.gradle.kts"); + + public static List dependencies(List dependencies) { + if (dependencies == null || dependencies.isEmpty()) { + return Collections.emptyList(); + } + return dependencies.stream() + .filter(dep -> !isBuildFileDependency(dep)) + .collect(Collectors.toList()); + } + + public static List classPaths(List dependencies, Path sourceFile) { + if (dependencies == null || dependencies.isEmpty()) { + return Collections.emptyList(); + } + Path sourceDir = sourceFile != null && sourceFile.getParent() != null ? sourceFile.getParent() : Util.getCwd(); + List resolved = new ArrayList<>(); + for (String dependency : dependencies) { + Path buildFile = toBuildFile(dependency, sourceDir); + if (buildFile != null) { + resolved.addAll(resolve(buildFile)); + } else if (isBuildFileDependency(dependency)) { + throw new ExitException(BaseCommand.EXIT_INVALID_INPUT, "Build file not found: " + dependency); + } + } + return resolved; + } + + public static boolean isBuildFileDependency(String dependency) { + if (dependency.startsWith("^")) { + return BUILD_FILES.contains(dependency.substring(1)); + } + Path fileName = Paths.get(dependency).getFileName(); + return fileName != null && BUILD_FILES.contains(fileName.toString()); + } + + private static Path toBuildFile(String dependency, Path sourceDir) { + if (dependency.startsWith("^")) { + String fileName = dependency.substring(1); + if (BUILD_FILES.contains(fileName)) { + return findNearest(sourceDir, fileName); + } + return null; + } + Path path = Paths.get(dependency); + if (!path.isAbsolute()) { + path = Util.getCwd().resolve(path); + } + path = path.normalize(); + if (Files.isRegularFile(path) && BUILD_FILES.contains(path.getFileName().toString())) { + return path; + } + return null; + } + + private static Path findNearest(Path dir, String fileName) { + Path cur = dir.toAbsolutePath().normalize(); + while (cur != null && cur.startsWith(Settings.getLocalRootDir())) { + Path candidate = cur.resolve(fileName); + if (Files.isRegularFile(candidate)) { + return candidate; + } + cur = cur.getParent(); + } + return null; + } + + private static List resolve(Path buildFile) { + try { + Util.infoMsg("Resolving classpath from " + buildFile); + Path cache = cacheFile(buildFile); + if (!Util.isFresh() && Files.isRegularFile(cache)) { + Util.verboseMsg("Using cached classpath from " + cache); + List cached = split(Util.readString(cache)); + Util.verboseMsg("Resolved classpath: " + String.join(File.pathSeparator, cached)); + return cached; + } + Files.createDirectories(cache.getParent()); + List result; + if (buildFile.getFileName().toString().equals("pom.xml")) { + runMaven(buildFile, cache); + result = split(Util.readString(cache)); + } else { + String cp = runGradle(buildFile); + Util.writeString(cache, cp); + result = split(cp); + } + Util.verboseMsg("Resolved classpath: " + String.join(File.pathSeparator, result)); + return result; + } catch (IOException e) { + throw new ExitException(BaseCommand.EXIT_GENERIC_ERROR, + "Could not resolve classpath from " + buildFile, e); + } + } + + private static Path cacheFile(Path buildFile) throws IOException { + String stat = buildFile.toAbsolutePath() + ":" + Files.size(buildFile) + ":" + + Files.getLastModifiedTime(buildFile).toMillis(); + return Settings.getCacheDir(Cache.CacheClass.deps) + .resolve("build-classpath-" + Util.getStableID(stat) + ".txt"); + } + + private static void runMaven(Path pom, Path outputFile) throws IOException { + Path dir = pom.getParent(); + List command = new ArrayList<>(); + command.add(mavenCommand(dir)); + command.add("-q"); + command.add("dependency:build-classpath"); + command.add("-Dmdep.outputFile=" + outputFile.toAbsolutePath()); + command.add("-Dmdep.pathSeparator=" + File.pathSeparator); + run(command, dir, false); + } + + private static String runGradle(Path buildFile) throws IOException { + Path dir = buildFile.getParent(); + Path init = Settings.getCacheDir(Cache.CacheClass.deps).resolve("jbang-classpath.gradle"); + Util.writeString(init, + "allprojects { plugins.withId('java') { tasks.register('jbangPrintClasspath') { doLast { println(sourceSets.main.runtimeClasspath.asPath) } } } }\n"); + List command = new ArrayList<>(); + command.add(gradleCommand(dir)); + command.add("-q"); + command.add("-I"); + command.add(init.toString()); + command.add("jbangPrintClasspath"); + return run(command, dir, true); + } + + private static String mavenCommand(Path dir) { + Path wrapper = dir.resolve(Util.isWindows() ? "mvnw.cmd" : "mvnw"); + return Files.isRegularFile(wrapper) ? wrapper.toString() : "mvn"; + } + + private static String gradleCommand(Path dir) { + Path wrapper = dir.resolve(Util.isWindows() ? "gradlew.bat" : "gradlew"); + return Files.isRegularFile(wrapper) ? wrapper.toString() : "gradle"; + } + + private static String run(List command, Path dir, boolean captureOutput) throws IOException { + Util.verboseMsg("Build classpath: " + String.join(" ", command)); + ProcessBuilder pb = new ProcessBuilder(command).directory(dir.toFile()); + if (!captureOutput) { + pb.inheritIO(); + } else { + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + } + Process process = pb.start(); + String output = ""; + if (captureOutput) { + try (InputStream is = process.getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = is.read(buffer)) != -1) { + baos.write(buffer, 0, read); + } + output = new String(baos.toByteArray(), StandardCharsets.UTF_8); + } + } + try { + process.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ExitException(BaseCommand.EXIT_GENERIC_ERROR, e); + } + if (process.exitValue() != 0) { + throw new ExitException(process.exitValue(), + "Could not resolve build classpath using: " + String.join(" ", command)); + } + return output; + } + + private static List split(String classPath) { + if (Util.isBlankString(classPath)) { + return Collections.emptyList(); + } + return Arrays.stream(classPath.trim().split(java.util.regex.Pattern.quote(File.pathSeparator))) + .filter(p -> !p.isEmpty()) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/dev/jbang/source/ProjectBuilder.java b/src/main/java/dev/jbang/source/ProjectBuilder.java index 76d0fa15bf..b7071b2ee4 100644 --- a/src/main/java/dev/jbang/source/ProjectBuilder.java +++ b/src/main/java/dev/jbang/source/ProjectBuilder.java @@ -33,6 +33,7 @@ import dev.jbang.catalog.Catalog; import dev.jbang.cli.BaseCommand; import dev.jbang.cli.ExitException; +import dev.jbang.dependencies.BuildSystemClassPaths; import dev.jbang.dependencies.DependencyResolver; import dev.jbang.dependencies.DependencyUtil; import dev.jbang.dependencies.Detector; @@ -363,8 +364,13 @@ private Project createJbangProject(ResourceRef resourceRef) { ResourceResolver resolver = getAliasResourceResolver(null); ResourceResolver sibRes2 = getSiblingResolver(resourceRef, resolver); for (String srcDep : directives.sourceDependencies()) { - ResourceRef subRef = resolver.resolve(srcDep, true); - prj.addSubProject(new ProjectBuilder(buildRefs).build(subRef)); + if (BuildSystemClassPaths.isBuildFileDependency(srcDep)) { + ss.addClassPaths(BuildSystemClassPaths.classPaths( + Collections.singletonList(srcDep), resourceRef.getFile())); + } else { + ResourceRef subRef = resolver.resolve(srcDep, true); + prj.addSubProject(new ProjectBuilder(buildRefs).build(subRef)); + } } boolean first = true; @@ -487,7 +493,9 @@ private static void copyManifestAttribute(Attributes attrs, Project prj, String private Project updateProject(Project prj) { SourceSet ss = prj.getMainSourceSet(); prj.addRepositories(allToMavenRepo(replaceAllProps(additionalRepos))); - ss.addDependencies(replaceAllProps(additionalDeps)); + List deps = replaceAllProps(additionalDeps); + ss.addDependencies(BuildSystemClassPaths.dependencies(deps)); + ss.addClassPaths(BuildSystemClassPaths.classPaths(deps, prj.getResourceRef().getFile())); ss.addClassPaths(replaceAllProps(additionalClasspaths)); updateAllSources(prj, replaceAllProps(additionalSources)); ss.addResources( @@ -682,8 +690,13 @@ private Project updateProject(Source src, Project prj, ResourceResolver resolver } } for (String srcDep : src.collectSourceDependencies()) { - ResourceRef subRef = sibRes1.resolve(srcDep, true); - prj.addSubProject(new ProjectBuilder(buildRefs).build(subRef)); + if (BuildSystemClassPaths.isBuildFileDependency(srcDep)) { + ss.addClassPaths(BuildSystemClassPaths.classPaths( + Collections.singletonList(srcDep), srcRef.getFile())); + } else { + ResourceRef subRef = sibRes1.resolve(srcDep, true); + prj.addSubProject(new ProjectBuilder(buildRefs).build(subRef)); + } } ResourceResolver sibRes2 = getSiblingResolver(srcRef, resolver); List includedSources = allToSource(src.getDirectives().sources(), srcRef, sibRes2); @@ -726,7 +739,7 @@ private ModularClassPath resolveDependency(String dep) { .addDependency(dep) .addRepositories(allToMavenRepo( replaceAllProps(additionalRepos))) - .addDependencies(replaceAllProps(additionalDeps)) + .addDependencies(BuildSystemClassPaths.dependencies(replaceAllProps(additionalDeps))) .addClassPaths( replaceAllProps(additionalClasspaths)); mcp = resolver.resolve(); diff --git a/src/test/java/dev/jbang/dependencies/TestBuildSystemClassPaths.java b/src/test/java/dev/jbang/dependencies/TestBuildSystemClassPaths.java new file mode 100644 index 0000000000..0374f9c201 --- /dev/null +++ b/src/test/java/dev/jbang/dependencies/TestBuildSystemClassPaths.java @@ -0,0 +1,84 @@ +package dev.jbang.dependencies; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import dev.jbang.BaseTest; +import dev.jbang.Settings; +import dev.jbang.source.Project; +import dev.jbang.util.Util; + +public class TestBuildSystemClassPaths extends BaseTest { + + @Test + void explicitPomClasspathUsesMavenWrapper() throws Exception { + assumeFalse(Util.isWindows()); + Path dep = cwdDir.resolve("dep.jar"); + Path pom = cwdDir.resolve("pom.xml"); + Files.write(dep, new byte[0]); + Files.write(pom, Collections.singletonList("")); + writeMvnw(cwdDir, dep.toString()); + Path src = cwdDir.resolve("main.java"); + Files.write(src, Collections.singletonList("class main {}")); + + Project prj = Project.builder() + .additionalDependencies(Collections.singletonList(pom.toString())) + .build(src); + + assertThat(prj.getMainSourceSet().getClassPaths(), contains(dep.toString())); + } + + @Test + void caretPomFindsNearestUpwardsFromSource() throws Exception { + assumeFalse(Util.isWindows()); + Path project = cwdDir.resolve("project"); + Path nested = project.resolve("src/main/java"); + Files.createDirectories(nested); + Path dep = project.resolve("dep.jar"); + Path pom = project.resolve("pom.xml"); + Files.write(dep, new byte[0]); + Files.write(pom, Collections.singletonList("")); + writeMvnw(project, dep.toString()); + Path src = nested.resolve("main.java"); + Files.write(src, Collections.singletonList("class main {}")); + + Project prj = Project.builder() + .additionalDependencies(Collections.singletonList("^pom.xml")) + .build(src); + + assertThat(prj.getMainSourceSet().getClassPaths(), contains(dep.toString())); + } + + @Test + void depsDirectiveWithPomXml() throws Exception { + assumeFalse(Util.isWindows()); + Path dep = cwdDir.resolve("dep.jar"); + Path pom = cwdDir.resolve("pom.xml"); + Files.write(dep, new byte[0]); + Files.write(pom, Collections.singletonList("")); + writeMvnw(cwdDir, dep.toString()); + Path src = cwdDir.resolve("main.java"); + Files.write(src, Collections.singletonList("//DEPS pom.xml\nclass main {}")); + + Project prj = Project.builder().build(src); + + assertThat(prj.getMainSourceSet().getClassPaths(), contains(dep.toString())); + } + + private static void writeMvnw(Path dir, String classpath) throws Exception { + Path mvnw = dir.resolve("mvnw"); + Files.write(mvnw, + Collections + .singletonList("#!/bin/sh\nfor arg in \"$@\"; do case \"$arg\" in -Dmdep.outputFile=*) echo '" + + classpath + "' > \"${arg#-Dmdep.outputFile=}\" ;; esac; done\n")); + mvnw.toFile().setExecutable(true); + Files.createDirectories(Settings.getCacheDir(dev.jbang.Cache.CacheClass.deps)); + } +}