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.nio.file.Path;
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.List;
028import java.util.function.Function;
029import java.util.stream.Collectors;
030import java.util.stream.Stream;
031import javax.tools.DiagnosticCollector;
032import javax.tools.JavaFileObject;
033import javax.tools.ToolProvider;
034import org.jdrupes.builder.api.BuildException;
035import org.jdrupes.builder.api.FileResource;
036import org.jdrupes.builder.api.FileTree;
037import static org.jdrupes.builder.api.Intent.*;
038import org.jdrupes.builder.api.MergedTestProject;
039import org.jdrupes.builder.api.Project;
040import static org.jdrupes.builder.api.Project.Properties.*;
041import org.jdrupes.builder.api.Resource;
042import org.jdrupes.builder.api.ResourceRequest;
043import org.jdrupes.builder.api.ResourceType;
044import static org.jdrupes.builder.api.ResourceType.*;
045import org.jdrupes.builder.api.Resources;
046import static org.jdrupes.builder.java.JavaTypes.*;
047
048/// The [JavaCompiler] generator provides two types of resources.
049/// 
050/// 1. The [JavaSourceFile]s of the project as configured with 
051///    [addSources(FileTree<JavaSourceFile>)][addSources]
052///    in response to a [ResourceRequest] with [ResourceType]
053///    [JavaTypes#JavaSourceTreeType] (or a more general type).
054///
055/// 2. The [ClassFile]s that result from compiling the sources in response
056///    to a [ResourceRequest] with [ResourceType]
057///    [JavaTypes#ClassTreeType] (or a more general type such as
058///    [JavaTypes#ClasspathElementType]).
059///
060/// No attempt has been made to define types for the options of
061/// the java compiler. Rather, the options are passed as strings
062/// as the [ToolProvider] API suggests. There are some noteworthy
063/// exceptions for options that are directly related to resource
064/// types (files, directory trees, paths) from the builder context.
065///
066/// If no "`-g...`" option is specified, the generator adds "`-g`" and
067/// thus generates full debug information. If you want to restore the
068/// default behavior of the java compiler, you have to specify
069/// "`-g:[lines, source]`" explicitly.
070///
071@SuppressWarnings("PMD.TooManyStaticImports")
072public class JavaCompiler extends JavaTool {
073
074    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
075    private final Resources<FileTree<JavaSourceFile>> sources
076        = project().newResource(new ResourceType<>() {});
077    private Path destination;
078
079    /// Initializes a new java compiler.
080    ///
081    /// @param project the project
082    ///
083    public JavaCompiler(Project project) {
084        super(project);
085        if (project instanceof MergedTestProject) {
086            destination = Path.of("test-classes");
087        } else {
088            destination = Path.of("classes");
089        }
090    }
091
092    /// Returns the destination directory. Defaults to "`classes`" for
093    /// "normal" projects and to "`test-classes`" for projects that
094    /// implement the [MergedTestProject] interface.
095    ///
096    /// @return the destination
097    ///
098    public Path destination() {
099        return project().buildDirectory().resolve(destination);
100    }
101
102    /// Sets the destination directory. The [Path] is resolved against
103    /// the project's build directory (see [Project#buildDirectory]).
104    ///
105    /// @param destination the new destination
106    /// @return the java compiler
107    ///
108    public JavaCompiler destination(Path destination) {
109        this.destination = destination;
110        return this;
111    }
112
113    /// Adds the source tree.
114    ///
115    /// @param sources the sources
116    /// @return the java compiler
117    ///
118    public final JavaCompiler addSources(FileTree<JavaSourceFile> sources) {
119        this.sources.add(sources);
120        return this;
121    }
122
123    /// Adds the files from the given directory matching the given pattern.
124    /// Short for
125    /// `addSources(project().newFileTree(directory, pattern, JavaSourceFile.class))`.
126    ///
127    /// @param directory the directory
128    /// @param pattern the pattern
129    /// @return the resources collector
130    ///
131    public final JavaCompiler addSources(Path directory, String pattern) {
132        addSources(
133            project().newResource(JavaSourceTreeType, directory, pattern));
134        return this;
135    }
136
137    /// Adds the sources.
138    ///
139    /// @param sources the sources
140    /// @return the java compiler
141    ///
142    public final JavaCompiler
143            addSources(Stream<FileTree<JavaSourceFile>> sources) {
144        this.sources.addAll(sources);
145        return this;
146    }
147
148    /// Return the source trees configured for the compiler.
149    ///
150    /// @return the resources
151    ///
152    public Resources<FileTree<JavaSourceFile>> sources() {
153        return sources;
154    }
155
156    /// Source paths.
157    ///
158    /// @return the collection
159    ///
160    private Collection<Path> sourcePaths() {
161        return sources.stream().map(Resources::stream)
162            .flatMap(Function.identity()).map(FileResource::path)
163            .collect(Collectors.toList());
164    }
165
166    @Override
167    protected <T extends Resource> Stream<T>
168            doProvide(ResourceRequest<T> requested) {
169        if (requested.accepts(JavaSourceTreeType)) {
170            @SuppressWarnings({ "unchecked" })
171            var result = (Stream<T>) sources.stream();
172            return result;
173        }
174
175        if (!requested.accepts(ClassTreeType)
176            && !requested.accepts(CleanlinessType)) {
177            return Stream.empty();
178        }
179
180        // Get this project's previously generated classes for checking
181        // or deleting.
182        var destDir = project().buildDirectory().resolve(destination);
183        final var classSet = project().newResource(ClassTreeType, destDir);
184        if (requested.accepts(CleanlinessType)) {
185            classSet.delete();
186            return Stream.empty();
187        }
188
189        // Get classpath for compilation. Filter myself, in case the
190        // compilation result is consumed by the project.
191        var cpResources = newResource(ClasspathType).addAll(
192            project().providers(Consume, Reveal, Expose).without(this)
193                .resources(of(ClasspathElementType)));
194        logger.atFiner().log("Compiling in %s with classpath %s", project(),
195            lazy(() -> cpResources.stream().map(e -> e.toPath().toString())
196                .collect(Collectors.joining(File.pathSeparator))));
197
198        // (Re-)compile only if necessary
199        var classesAsOf = classSet.asOf();
200        if (sources.asOf().isAfter(classesAsOf)
201            || cpResources.asOf().isAfter(classesAsOf)
202            || classSet.stream().count() < sources.stream()
203                .flatMap(Resources::stream).map(FileResource::path)
204                .filter(p -> p.toString().endsWith(".java")
205                    && !p.endsWith("package-info.java")
206                    && !p.endsWith("module-info.java"))
207                .count()) {
208            classSet.delete();
209            compile(cpResources, destDir);
210        } else {
211            logger.atFiner().log("Classes in %s are up to date", project());
212        }
213        classSet.clear();
214        @SuppressWarnings("unchecked")
215        var result = (Stream<T>) Stream.of(classSet);
216        return result;
217    }
218
219    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
220        "PMD.ExceptionAsFlowControl" })
221    private void compile(Resources<ClasspathElement> cpResources,
222            Path destDir) {
223        logger.atInfo().log("Compiling Java in %s", project().name());
224        var classpath = cpResources.stream().map(e -> e.toPath().toString())
225            .collect(Collectors.joining(File.pathSeparator));
226        var javac = ToolProvider.getSystemJavaCompiler();
227        var diagnostics = new DiagnosticCollector<JavaFileObject>();
228        try (var fileManager
229            = javac.getStandardFileManager(diagnostics, null, null)) {
230            var compilationUnits
231                = fileManager.getJavaFileObjectsFromPaths(sourcePaths());
232            List<String> allOptions = new ArrayList<>(options());
233
234            // If no -g... option is given, add -g (full debug info)
235            if (allOptions.stream()
236                .filter(o -> o.startsWith("-g")).findAny().isEmpty()) {
237                allOptions.add("-g");
238            }
239
240            // Add options from specific properties
241            allOptions.addAll(List.of(
242                "-d", destDir.toString(),
243                "-cp", classpath,
244                "-encoding", project().get(Encoding).toString()));
245            if (!javac.getTask(null, fileManager, null, allOptions, null,
246                compilationUnits).call()) {
247                throw new BuildException("Compilation failed");
248            }
249        } catch (Exception e) {
250            logger.atSevere().withCause(e)
251                .log("Project %s: Problem compiling Java: %s", project().name(),
252                    e.getMessage());
253            throw new BuildException().from(this).cause(e);
254        } finally {
255            logDiagnostics(diagnostics);
256        }
257    }
258}