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