001/* 002 * JDrupes Builder 003 * Copyright (C) 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.java; 020 021import com.google.common.flogger.FluentLogger; 022import static com.google.common.flogger.LazyArgs.*; 023import io.vavr.control.Option; 024import io.vavr.control.Try; 025import java.io.File; 026import java.io.IOException; 027import java.io.InputStream; 028import java.io.OutputStream; 029import java.lang.ProcessBuilder.Redirect; 030import java.nio.file.Path; 031import java.util.ArrayList; 032import java.util.Arrays; 033import java.util.Collection; 034import java.util.Collections; 035import java.util.List; 036import java.util.Objects; 037import java.util.jar.Attributes; 038import java.util.jar.Manifest; 039import java.util.stream.Collectors; 040import java.util.stream.Stream; 041import org.jdrupes.builder.api.BuildException; 042import org.jdrupes.builder.api.ConfigurationException; 043import org.jdrupes.builder.api.ExecResult; 044import org.jdrupes.builder.api.FileResource; 045import org.jdrupes.builder.api.FileTree; 046import org.jdrupes.builder.api.Project; 047import org.jdrupes.builder.api.Renamable; 048import org.jdrupes.builder.api.RequiredResourceSupport; 049import org.jdrupes.builder.api.Resource; 050import org.jdrupes.builder.api.ResourceProvider; 051import org.jdrupes.builder.api.ResourceRequest; 052import org.jdrupes.builder.api.ResourceRetriever; 053import org.jdrupes.builder.api.ResourceType; 054import static org.jdrupes.builder.api.ResourceType.*; 055import org.jdrupes.builder.api.Resources; 056import org.jdrupes.builder.core.AbstractProvider; 057import org.jdrupes.builder.core.StreamCollector; 058import static org.jdrupes.builder.java.JavaTypes.*; 059 060/// A provider for [execution results][ExecResult]s from invoking a JVM. 061/// 062/// The working directory is the project directory. 063/// 064/// ## JPMS support 065/// 066/// When [module] is set, execution uses `--module` instead of `-cp`. 067/// Modular classpath elements are placed on the `--module-path` and 068/// the main class is specified as `module/class`. Non-modular elements 069/// remain on the classpath (`-cp`). 070/// 071public class JavaExecutor extends AbstractProvider 072 implements ResourceRetriever, Renamable, RequiredResourceSupport { 073 074 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 075 private final Project project; 076 private final StreamCollector<Resource> requiredResources 077 = new StreamCollector<>(false); 078 private final StreamCollector<ResourceProvider> providers 079 = StreamCollector.cached(); 080 private String mainClass; 081 private String module; 082 private final List<String> arguments = new ArrayList<>(); 083 084 /// Initializes a new java executor. 085 /// 086 /// @param project the project 087 /// 088 public JavaExecutor(Project project) { 089 this.project = project; 090 rename(JavaExecutor.class.getSimpleName() + " in " + project); 091 } 092 093 @Override 094 public JavaExecutor name(String name) { 095 rename(name); 096 return this; 097 } 098 099 @Override 100 public JavaExecutor required(Stream<? extends Resource> resources) { 101 requiredResources.add(resources); 102 return this; 103 } 104 105 @Override 106 public JavaExecutor required(Path root, String pattern) { 107 requiredResources 108 .add(Stream.of(FileTree.of(project, root, pattern))); 109 return this; 110 } 111 112 @Override 113 public JavaExecutor required(Path root) { 114 requiredResources.add( 115 Stream.of(FileResource.of(project.directory().resolve(root)))); 116 return this; 117 } 118 119 /// Additionally uses the given providers for obtaining required resources. 120 /// 121 /// @param providers the providers 122 /// @return the java executor 123 /// 124 @Override 125 public JavaExecutor addFrom(ResourceProvider... providers) { 126 addFrom(Stream.of(providers)); 127 return this; 128 } 129 130 /// Additionally uses the given providers for obtaining required resources. 131 /// 132 /// @param providers the providers 133 /// @return the java executor 134 /// 135 @Override 136 public JavaExecutor addFrom(Stream<ResourceProvider> providers) { 137 this.providers.add(providers.filter(p -> !p.equals(this))); 138 return this; 139 } 140 141 /// Returns the main class. 142 /// 143 /// @return the main class 144 /// 145 public String mainClass() { 146 return mainClass; 147 } 148 149 /// Sets the main class. 150 /// 151 /// @param mainClass the new main class 152 /// @return the jar generator for method chaining 153 /// 154 public JavaExecutor mainClass(String mainClass) { 155 this.mainClass = Objects.requireNonNull(mainClass); 156 return this; 157 } 158 159 /// Returns the module name for module-path execution. 160 /// 161 /// @return the module name 162 /// 163 public String module() { 164 return module; 165 } 166 167 /// Sets the module name for module-path execution. When set, the 168 /// executor uses `--module module/class` instead of `-cp ... class`. 169 /// Modular code contributions are placed on `--module-path`. 170 /// 171 /// @param module the module name 172 /// @return this executor 173 /// 174 public JavaExecutor module(String module) { 175 this.module = Objects.requireNonNull(module); 176 return this; 177 } 178 179 /// Add the given arguments. 180 /// 181 /// @param args the arguments 182 /// @return the java executor 183 /// 184 public JavaExecutor args(String... args) { 185 arguments.addAll(Arrays.asList(args)); 186 return this; 187 } 188 189 @Override 190 protected <T extends Resource> Collection<T> 191 doProvide(ResourceRequest<T> requested) { 192 // Check if provided and evaluate for most special type 193 if (!requested.accepts(ExecResultType) 194 || requested.name().map(n -> !n.equals(name())).orElse(false)) { 195 return Collections.emptyList(); 196 } 197 if (!requested.isFor(ExecResultType)) { 198 @SuppressWarnings({ "unchecked" }) 199 var result = (Collection<T>) resources(of(ExecResultType) 200 .withName(name())).toList(); 201 return result; 202 } 203 204 // Make sure that the required resources are retrieved and exist 205 var required = Resources.of(new ResourceType<Resources<Resource>>() {}); 206 required.addAll(requiredResources.stream()); 207 208 // Collect the classpath and check mainClass. 209 var cpResources = Resources.of(CodeContributionsType) 210 .addAll(providers.stream() 211 .map(p -> p.resources(of(CodeContributionType).usingAll())) 212 // Terminate to trigger all future stream evaluations before 213 // starting to process the results. 214 .toList().stream().flatMap(s -> s)); 215 216 if (mainClass == null) { 217 findMainClass(cpResources); 218 } 219 if (mainClass == null) { 220 throw new ConfigurationException().from(this).message( 221 "No main class defined for %s", name()); 222 } 223 224 // Build command 225 List<String> command; 226 if (module != null) { 227 command = buildModuleCommand(cpResources); 228 } else { 229 command = buildClasspathCommand(cpResources); 230 } 231 logger.atInfo().log("Executing %s", 232 command.stream().collect(Collectors.joining(" "))); 233 234 ProcessBuilder processBuilder = new ProcessBuilder(command) 235 .directory(project.directory().toFile()) 236 .redirectInput(Redirect.INHERIT); 237 try { 238 Process process = processBuilder.start(); 239 copyData(process.getInputStream(), context().out()); 240 copyData(process.getErrorStream(), context().error()); 241 var execResult 242 = ExecResult.of(this, mainClass, process.waitFor()); 243 if (execResult.exitValue() != 0) { 244 execResult.setFaulty(); 245 } 246 @SuppressWarnings("unchecked") 247 var result = (Collection<T>) List.of(execResult); 248 return result; 249 } catch (IOException | InterruptedException e) { 250 throw new BuildException().from(this).cause(e); 251 } 252 } 253 254 /// Builds a classpath-based java command. 255 /// 256 /// @param cpResources the classpath resources 257 /// @return the command list 258 /// 259 private List<String> buildClasspathCommand( 260 Resources<CodeContribution> cpResources) { 261 var classpath = cpResources.stream().map(e -> e.toPath().toString()) 262 .collect(Collectors.joining(File.pathSeparator)); 263 logger.atFiner().log("Executing with classpath %s", 264 lazy(() -> classpath)); 265 List<String> command = new ArrayList<>(List.of( 266 System.getProperty("java.home") + "/bin/java", 267 "-cp", classpath, 268 mainClass)); 269 command.addAll(arguments); 270 return command; 271 } 272 273 /// Builds a module-path java command with `--module module/class`. 274 /// 275 /// @param codeContributions the code contributions 276 /// @return the command list 277 /// 278 private List<String> buildModuleCommand( 279 Resources<CodeContribution> codeContributions) { 280 var moduleElements = codeContributions.stream() 281 .filter(cc -> cc.isModular()).toList(); 282 var classpathElements = codeContributions.stream() 283 .filter(cc -> !cc.isModular()).toList(); 284 285 List<String> command = new ArrayList<>(List.of( 286 System.getProperty("java.home") + "/bin/java")); 287 288 // Module-path 289 if (!moduleElements.isEmpty()) { 290 var modulePath = moduleElements.stream() 291 .map(e -> e.toPath().toString()) 292 .collect(Collectors.joining(File.pathSeparator)); 293 command.add("--module-path"); 294 command.add(modulePath); 295 logger.atFiner().log("Executing with module-path %s", 296 lazy(() -> modulePath)); 297 } 298 299 // Classpath for non-modular elements 300 if (!classpathElements.isEmpty()) { 301 var classpath = classpathElements.stream() 302 .map(e -> e.toPath().toString()) 303 .collect(Collectors.joining(File.pathSeparator)); 304 command.add("-cp"); 305 command.add(classpath); 306 logger.atFiner().log("Executing with classpath %s", 307 lazy(() -> classpath)); 308 } 309 310 // --module module/class 311 command.add("--module"); 312 command.add(module + "/" + mainClass); 313 command.addAll(arguments); 314 return command; 315 } 316 317 private void findMainClass(Resources<CodeContribution> cpResources) { 318 vavrStream(cpResources).filter(cpe -> cpe instanceof JarFile) 319 .map(JarFile.class::cast).map(cpe -> Try.withResources( 320 () -> new java.util.jar.JarFile(cpe.path().toFile())) 321 .of(jar -> Try.of(jar::getManifest).toOption() 322 .flatMap(Option::of).map(Manifest::getMainAttributes) 323 .flatMap(a -> Option 324 .of(a.getValue(Attributes.Name.MAIN_CLASS)))) 325 .onFailure(e -> logger.atWarning().withCause(e).log( 326 "Problem reading %s", cpe)) 327 .toOption().flatMap(s -> s)) 328 .flatMap(Option::toStream).headOption().peek(mc -> mainClass = mc); 329 } 330 331 private void copyData(InputStream source, OutputStream sink) { 332 Thread.startVirtualThread(() -> { 333 try (source) { 334 source.transferTo(sink); 335 } catch (IOException e) { // NOPMD 336 } 337 }); 338 } 339 340 /// To string. 341 /// 342 /// @return the string 343 /// 344 @Override 345 public String toString() { 346 return super.toString() + "[" + project.name() + "]"; 347 } 348}