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