001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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 java.io.File;
024import java.io.IOException;
025import java.nio.file.Files;
026import java.nio.file.Path;
027import java.util.ArrayList;
028import java.util.Collection;
029import java.util.Collections;
030import java.util.List;
031import java.util.function.Function;
032import java.util.regex.Matcher;
033import java.util.regex.Pattern;
034import java.util.stream.Collectors;
035import java.util.stream.Stream;
036import javax.tools.DiagnosticCollector;
037import javax.tools.JavaFileObject;
038import javax.tools.StandardJavaFileManager;
039import javax.tools.StandardLocation;
040import javax.tools.ToolProvider;
041import org.jdrupes.builder.api.BuildException;
042import static org.jdrupes.builder.api.CoreProperties.*;
043import org.jdrupes.builder.api.FileResource;
044import org.jdrupes.builder.api.FileTree;
045import static org.jdrupes.builder.api.Intent.*;
046import org.jdrupes.builder.api.MergedTestProject;
047import org.jdrupes.builder.api.Project;
048import org.jdrupes.builder.api.Resource;
049import org.jdrupes.builder.api.ResourceRequest;
050import org.jdrupes.builder.api.ResourceType;
051import static org.jdrupes.builder.api.ResourceType.*;
052import org.jdrupes.builder.api.Resources;
053import org.jdrupes.builder.api.UnavailableException;
054import static org.jdrupes.builder.java.JavaTypes.*;
055
056/// The [JavaCompiler] generator provides two types of resources.
057///
058/// 1. The [JavaSourceFile]s of the project as configured with
059///    [addSources(FileTree<JavaSourceFile>)][addSources]
060///    in response to a [ResourceRequest] with [ResourceType]
061///    [JavaTypes#JavaSourceTreeType] (or a more general type).
062///
063/// 2. The [ClassFile]s that result from compiling the sources in response
064///    to a [ResourceRequest] with [ResourceType]
065///    [JavaTypes#ClassTreeType] (or a more general type such as
066///    [JavaTypes#CodeContributionType]).
067///
068/// No attempt has been made to define types for the options of
069/// the java compiler. Rather, the options are passed as strings
070/// as the [ToolProvider] API suggests. There are some noteworthy
071/// exceptions for options that are directly related to resource
072/// types (files, directory trees, paths) from the builder context.
073///
074/// If no "`-g...`" option is specified, the generator adds "`-g`" and
075/// thus generates full debug information. If you want to restore the
076/// default behavior of the java compiler, you have to specify
077/// "`-g:[lines, source]`" explicitly.
078///
079/// ## JPMS support
080///
081/// When the sources contain a `module-info.java`, or when the module mode
082/// is set to `ModuleMode.MODULE`, compilation uses the module-path
083/// (`--module-path`) for code contributions that contain a module descriptor.
084/// Non-modular code contributions remain on the classpath (`-cp`). In
085/// `ModuleMode.AUTO` mode (the default), module compilation is activated
086/// automatically when a `module-info.java` is found among the sources.
087///
088public class JavaCompiler extends JavaTool {
089
090    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
091
092    private final Resources<FileTree<JavaSourceFile>> sources
093        = Resources.of(new ResourceType<>() {});
094    private Path destination;
095
096    /// Initializes a new java compiler.
097    ///
098    /// @param project the project
099    ///
100    public JavaCompiler(Project project) {
101        super(project);
102        if (project instanceof MergedTestProject) {
103            destination = Path.of("test-classes");
104        } else {
105            destination = Path.of("classes");
106        }
107    }
108
109    /// Returns the destination directory. Defaults to "`classes`" for
110    /// "normal" projects and to "`test-classes`" for projects that
111    /// implement the [MergedTestProject] interface.
112    ///
113    /// @return the destination
114    ///
115    public Path destination() {
116        return project().buildDirectory().resolve(destination);
117    }
118
119    /// Sets the destination directory. The [Path] is resolved against
120    /// the project's build directory (see [Project#buildDirectory]).
121    ///
122    /// @param destination the new destination
123    /// @return the java compiler
124    ///
125    public JavaCompiler destination(Path destination) {
126        this.destination = destination;
127        return this;
128    }
129
130    /// Adds the source tree.
131    ///
132    /// @param sources the sources
133    /// @return the java compiler
134    ///
135    public final JavaCompiler addSources(FileTree<JavaSourceFile> sources) {
136        this.sources.add(sources);
137        return this;
138    }
139
140    /// Adds the files from the given directory matching the given pattern.
141    /// Short for
142    /// `addSources(project().newFileTree(directory, pattern, JavaSourceFile.class))`.
143    ///
144    /// @param directory the directory
145    /// @param pattern the pattern
146    /// @return the java compiler
147    ///
148    public final JavaCompiler addSources(Path directory, String pattern) {
149        addSources(FileTree.of(
150            project(), directory, JavaSourceFile.class, pattern));
151        return this;
152    }
153
154    /// Adds the sources.
155    ///
156    /// @param sources the sources
157    /// @return the java compiler
158    ///
159    public final JavaCompiler
160            addSources(Stream<FileTree<JavaSourceFile>> sources) {
161        this.sources.addAll(sources);
162        return this;
163    }
164
165    /// Returns the source trees configured for the compiler.
166    ///
167    /// @return the resources
168    ///
169    public Resources<FileTree<JavaSourceFile>> sources() {
170        return sources;
171    }
172
173    /// Returns the source paths.
174    ///
175    /// @return the collection
176    ///
177    private Collection<Path> sourcePaths() {
178        return sources.stream().map(Resources::stream)
179            .flatMap(Function.identity()).map(FileResource::path)
180            .collect(Collectors.toList());
181    }
182
183    @Override
184    protected boolean hasModuleInfo() {
185        return sources.stream().flatMap(Resources::stream)
186            .anyMatch(f -> f.path().endsWith("module-info.java"));
187    }
188
189    /// Extracts the module name from `module-info.java` by parsing the
190    /// first `module <name>` declaration. Returns `null` if no module
191    /// descriptor source is found.
192    ///
193    /// @return the module name, or null
194    ///
195    private String extractModuleName() {
196        var pattern = Pattern.compile("^\\s*module\\s+(\\S+)");
197        for (var sourceTree : sources.get()) {
198            for (var file : sourceTree.get()) {
199                if (file.path().endsWith("module-info.java")) {
200                    try {
201                        var content = Files.readString(file.path());
202                        Matcher matcher = pattern.matcher(content);
203                        if (matcher.find()) {
204                            return matcher.group(1);
205                        }
206                    } catch (IOException e) {
207                        logger.atWarning().withCause(e)
208                            .log("Cannot read %s", file.path());
209                    }
210                }
211            }
212        }
213        return null;
214    }
215
216    @Override
217    protected <T extends Resource> Collection<T>
218            doProvide(ResourceRequest<T> requested) {
219        if (requested.accepts(JavaSourceTreeType)) {
220            @SuppressWarnings({ "unchecked" })
221            var result = (Collection<T>) sources.get();
222            return result;
223        }
224
225        if (!requested.accepts(ClassTreeType)
226            && !requested.accepts(CleanlinessType)) {
227            return Collections.emptyList();
228        }
229
230        // Get this project's previously generated classes for checking
231        // or deleting.
232        var destDir = project().buildDirectory().resolve(destination);
233        final var classSet = ClassTree.of(project(), destDir);
234        if (requested.accepts(CleanlinessType)) {
235            classSet.cleanup();
236            return Collections.emptyList();
237        }
238
239        // Evaluate for most special type
240        if (requested.accepts(ClassTreeType)
241            && !requested.type().equals(ClassTreeType)) {
242            @SuppressWarnings("unchecked")
243            var result = (Collection<T>) resources(of(ClassTreeType)).toList();
244            return result;
245        }
246
247        // Get classpath for compilation. Filter myself, in case the
248        // compilation result is consumed by the project.
249        var cpResources = Resources.of(CodeContributionsType).addAll(
250            project().providers(Consume, Reveal, Expose).without(this)
251                .resources(of(CodeContributionType)));
252
253        // (Re-)compile only if necessary
254        var useModulePath = effectiveModuleMode() == ModuleMode.MODULE;
255        var expectedClassCount = sources.stream()
256            .flatMap(Resources::stream).map(FileResource::path)
257            .filter(p -> p.toString().endsWith(".java")
258                && !p.endsWith("package-info.java")
259                && (useModulePath || !p.endsWith("module-info.java")))
260            .count();
261        if (sources.isNewerThan(classSet)
262            || cpResources.isNewerThan(classSet)
263            || classSet.stream().count() < expectedClassCount) {
264            classSet.cleanup();
265            compile(cpResources, destDir);
266        } else {
267            logger.atFine().log("%s found classes to be up to date", this);
268        }
269        classSet.clear();
270        @SuppressWarnings("unchecked")
271        var result = (Collection<T>) List.of(classSet);
272        return result;
273    }
274
275    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
276        "PMD.ExceptionAsFlowControl" })
277    private void compile(Resources<CodeContribution> ccResources,
278            Path destDir) {
279        logger.atInfo().log("Compiling Java in %s", project().name());
280        var javac = ToolProvider.getSystemJavaCompiler();
281        var diagnostics = new DiagnosticCollector<JavaFileObject>();
282        var useModulePath = effectiveModuleMode() == JavaTool.ModuleMode.MODULE;
283        if (useModulePath) {
284            logger.atFine().log("Using module-path compilation");
285        }
286
287        try (var fileManager
288            = javac.getStandardFileManager(diagnostics, null, null)) {
289            List<String> allOptions = new ArrayList<>(options());
290
291            // If no -g... option is given, add -g (full debug info)
292            if (allOptions.stream()
293                .filter(o -> o.startsWith("-g")).findAny().isEmpty()) {
294                allOptions.add("-g");
295            }
296
297            // Add common options
298            allOptions.addAll(List.of(
299                "-d", destDir.toString(),
300                "-encoding", project().get(Encoding)));
301
302            if (useModulePath) {
303                compileWithModulePath(fileManager, ccResources, allOptions);
304            } else {
305                compileWithClasspath(fileManager, ccResources);
306            }
307
308            var compilationUnits
309                = fileManager.getJavaFileObjectsFromPaths(sourcePaths());
310            if (!javac.getTask(null, fileManager, diagnostics, allOptions,
311                null, compilationUnits).call()) {
312                throw new UnavailableException().from(this);
313            }
314        } catch (Exception e) {
315            logger.atSevere().withCause(e)
316                .log("Project %s: Problem compiling Java: %s", project().name(),
317                    e.getMessage());
318            throw new BuildException().from(this).cause(e);
319        } finally {
320            logDiagnostics(diagnostics);
321            logger.atFine().log("%s finished compilation", this);
322        }
323    }
324
325    /// Configures the options for plain classpath compilation.
326    ///
327    /// @param fileManager the file manager
328    /// @param ccResources the classpath resources
329    /// @param allOptions the option list to modify
330    /// @throws IOException 
331    ///
332    private void compileWithClasspath(StandardJavaFileManager fileManager,
333            Resources<CodeContribution> ccResources)
334            throws IOException {
335        var classpathElements = ccResources.stream().toList();
336        if (!classpathElements.isEmpty()) {
337            var cpPaths = classpathElements.stream()
338                .map(e -> e.toPath().toFile()).toList();
339            fileManager.setLocation(StandardLocation.CLASS_PATH, cpPaths);
340        }
341        logger.atFiner().log("%s uses classpath %s", this,
342            lazy(() -> classpathElements.stream()
343                .map(e -> e.toPath().toString())
344                .collect(Collectors.joining(File.pathSeparator))));
345    }
346
347    /// Configures the file manager and options for module-path compilation.
348    /// Modular dependencies go to --module-path, non-modular stay on -cp.
349    ///
350    /// @param fileManager the file manager
351    /// @param ccResources the classpath resources
352    /// @param allOptions the option list to modify
353    /// @throws IOException 
354    ///
355    @SuppressWarnings("PMD.ConfusingTernary")
356    private void compileWithModulePath(StandardJavaFileManager fileManager,
357            Resources<CodeContribution> ccResources, List<String> allOptions)
358            throws IOException {
359        var moduleElements = ccResources.stream()
360            .filter(cc -> cc.isModular()).toList();
361        var classpathElements = ccResources.stream()
362            .filter(cc -> !cc.isModular()).toList();
363
364        if (!moduleElements.isEmpty()) {
365            var modulePaths = moduleElements.stream()
366                .map(e -> e.toPath().toFile()).toList();
367            fileManager.setLocation(StandardLocation.MODULE_PATH, modulePaths);
368            logger.atFiner().log("%s uses module-path %s", this,
369                lazy(() -> moduleElements.stream()
370                    .map(e -> e.toPath().toString())
371                    .collect(Collectors.joining(File.pathSeparator))));
372        }
373
374        if (!classpathElements.isEmpty()) {
375            var cpPaths = classpathElements.stream()
376                .map(e -> e.toPath().toFile()).toList();
377            fileManager.setLocation(StandardLocation.CLASS_PATH, cpPaths);
378
379            // Allow the named module to read from the unnamed module
380            var moduleName = extractModuleName();
381            if (moduleName != null) {
382                allOptions.add("--add-reads");
383                allOptions.add(moduleName + "=ALL-UNNAMED");
384            }
385            logger.atFiner().log("%s uses classpath %s", this,
386                lazy(() -> classpathElements.stream()
387                    .map(e -> e.toPath().toString())
388                    .collect(Collectors.joining(File.pathSeparator))));
389        } else {
390            logger.atFiner().log("%s compiling as pure module", this);
391        }
392    }
393}