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}