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.Arrays;
027import java.util.Collection;
028import java.util.Collections;
029import java.util.List;
030import java.util.function.Function;
031import java.util.stream.Collectors;
032import java.util.stream.Stream;
033import javax.tools.DiagnosticCollector;
034import javax.tools.JavaFileObject;
035import javax.tools.ToolProvider;
036import org.jdrupes.builder.api.BuildException;
037import org.jdrupes.builder.api.ConfigurationException;
038import org.jdrupes.builder.api.FileResource;
039import org.jdrupes.builder.api.FileTree;
040import org.jdrupes.builder.api.Intent;
041import static org.jdrupes.builder.api.Intent.*;
042import org.jdrupes.builder.api.Project;
043import org.jdrupes.builder.api.Resource;
044import org.jdrupes.builder.api.ResourceRequest;
045import static org.jdrupes.builder.api.ResourceType.*;
046import org.jdrupes.builder.api.Resources;
047import org.jdrupes.builder.api.UnavailableException;
048import org.jdrupes.builder.core.StreamCollector;
049import static org.jdrupes.builder.java.JavaTypes.*;
050
051/// The [Javadoc] generator provides the resource [JavadocDirectory],
052/// a directory that contains generated javadoc files.
053///
054/// No attempt is made to define dedicated types for Javadoc tool options.
055/// Instead, options are passed as strings, as suggested by the
056/// [ToolProvider] API. Notable exceptions exist for options that are
057/// directly related to resource types originating from the builder
058/// context (such as files, directory trees, and paths).
059/// 
060/// By default, the generator builds the Javadoc for the project passed
061/// to the constructor. In some cases – such as generating a shared
062/// Javadoc for multiple projects – this behavior is not desired. In
063/// these cases, the project(s) for which Javadoc is generated can be
064/// configured via [#projects(Stream)].
065///
066/// The classpath and module-path for the Javadoc tool invocation are
067/// assembled by requesting the [CodeContribution]s with intents
068/// [Consume][Intent#Consume], [Reveal][Intent#Reveal] and
069/// [Expose][Intent#Expose] from the configured project(s).
070/// 
071/// The [JavaSourceFile]s to be processed are obtained by requesting
072/// resources of [JavaTypes#JavaSourceTreeType] with intents
073/// [Supply][Intent#Supply] and [Expose][Intent#Expose] from the configured
074/// project(s). This default behavior can be overridden by setting the
075/// sources explicitly using one or more invocations of the `addSources`
076/// methods.
077///
078/// ## JPMS support
079///
080/// When the sources contain a `module-info.java`, or when the module mode
081/// is set to `ModuleMode.MODULE`, generation uses the module-path
082/// (`--module-path`) for code contributions that contain a module descriptor.
083/// Non-modular code contributions remain on the classpath (`-cp`). In
084/// `ModuleMode.AUTO` mode (the default), module compilation is activated
085/// automatically when a `module-info.java` is found among the sources.
086///
087public class Javadoc extends JavaTool {
088
089    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
090    private final StreamCollector<FileTree<JavaSourceFile>> sources
091        = StreamCollector.cached();
092    private StreamCollector<Project> projects = StreamCollector.cached();
093    private Path destination = Path.of("doc");
094    private final StreamCollector<CodeContribution> tagletpath
095        = StreamCollector.cached();
096    private final List<String> taglets = new ArrayList<>();
097
098    /// Instantiates a new java compiler.
099    ///
100    /// @param project the project
101    ///
102    public Javadoc(Project project) {
103        super(project);
104        projects.add(project);
105    }
106
107    /// Sets the projects to generate javadoc for.
108    ///
109    /// @param projects the projects
110    /// @return the javadoc
111    ///
112    public Javadoc projects(Stream<Project> projects) {
113        this.projects = StreamCollector.cached();
114        this.projects.add(projects);
115        return this;
116    }
117
118    /// Returns the destination directory. Defaults to "`doc`".
119    ///
120    /// @return the destination
121    ///
122    public Path destination() {
123        return destination;
124    }
125
126    /// Sets the destination directory. The [Path] is resolved against
127    /// the project's build directory (see [Project#buildDirectory]).
128    ///
129    /// @param destination the new destination
130    /// @return the javadoc generator
131    ///
132    public Javadoc destination(Path destination) {
133        this.destination = destination;
134        return this;
135    }
136
137    /// Adds the source tree.
138    ///
139    /// @param sources the sources
140    /// @return the javadoc generator
141    ///
142    @SafeVarargs
143    public final Javadoc addSources(FileTree<JavaSourceFile>... sources) {
144        this.sources.add(Arrays.stream(sources));
145        return this;
146    }
147
148    /// Adds the files from the given directory matching the given pattern.
149    /// Short for
150    /// `addSources(project().newFileTree(directory, pattern, JavaSourceFile.class))`.
151    ///
152    /// @param directory the directory
153    /// @param pattern the pattern
154    /// @return the javadoc generator
155    /// 
156    public final Javadoc addSources(Path directory, String pattern) {
157        addSources(FileTree.of(
158            project(), directory, JavaSourceFile.class, pattern));
159        return this;
160    }
161
162    /// Adds the sources.
163    ///
164    /// @param sources the sources
165    /// @return the java compiler
166    ///
167    public final Javadoc addSources(Stream<FileTree<JavaSourceFile>> sources) {
168        this.sources.add(sources);
169        return this;
170    }
171
172    /// Source paths.
173    ///
174    /// @return the collection
175    ///
176    private Collection<Path> sourcePaths(
177            Stream<FileTree<JavaSourceFile>> sources) {
178        return sources.map(Resources::stream)
179            .flatMap(Function.identity()).map(FileResource::path)
180            .collect(Collectors.toSet());
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    /// Adds the given classpath elements to the tagletpath. The classpath
190    /// elements can be classpath elements or modules.
191    ///
192    /// @param classpathElements the classpath elements
193    /// @return the javadoc
194    ///
195    public Javadoc
196            tagletpath(Stream<? extends CodeContribution> classpathElements) {
197        tagletpath.add(classpathElements);
198        return this;
199    }
200
201    /// Adds the given taglets.
202    ///
203    /// @param taglets the taglets
204    /// @return the javadoc
205    ///
206    public Javadoc taglets(Stream<String> taglets) {
207        this.taglets.addAll(taglets.toList());
208        return this;
209    }
210
211    @Override
212    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
213        "PMD.ExceptionAsFlowControl" })
214    protected <T extends Resource> Collection<T>
215            doProvide(ResourceRequest<T> requested) {
216        if (!requested.accepts(JavadocDirectoryType)
217            && !requested.accepts(CleanlinessType)) {
218            return Collections.emptyList();
219        }
220
221        // Get destination and check if we only have to cleanup.
222        var destDir = project().buildDirectory().resolve(destination);
223        var generated = ClassTree.of(project(), destDir);
224        if (requested.accepts(CleanlinessType)) {
225            generated.cleanup();
226            destDir.toFile().delete();
227            return Collections.emptyList();
228        }
229
230        // Always evaluate for most special type
231        if (!requested.type().equals(JavadocDirectoryType)) {
232            @SuppressWarnings("unchecked")
233            var result
234                = (Collection<T>) resources(of(JavadocDirectoryType)).toList();
235            return result;
236        }
237
238        // Generate
239        var javadoc = ToolProvider.getSystemDocumentationTool();
240        var diagnostics = new DiagnosticCollector<JavaFileObject>();
241        try (var fileManager
242            = javadoc.getStandardFileManager(diagnostics, null, null)) {
243            List<String> allOptions = evaluateOptions(destDir);
244            logger.atFinest().log("Javadoc options: %s", allOptions);
245            var sourcePaths = sourcePaths(sources.stream());
246            if (sourcePaths.isEmpty()) {
247                sourcePaths = sourcePaths(projects.stream().flatMap(p -> p
248                    .resources(of(JavaSourceTreeType).using(Supply, Expose))));
249            }
250            var finalSourcePaths = sourcePaths;
251            logger.atFinest().log("Javadoc sources: %s", finalSourcePaths);
252            var sourceFiles
253                = fileManager.getJavaFileObjectsFromPaths(sourcePaths);
254            if (!javadoc.getTask(null, fileManager, diagnostics, null,
255                allOptions, sourceFiles).call()) {
256                throw new UnavailableException().from(this);
257            }
258        } catch (Exception e) {
259            logger.atSevere().withCause(e).log(
260                "Project %s: Cannot generate Javadoc: %s",
261                project().name(), e.getMessage());
262            throw new BuildException().from(this).cause(e);
263        } finally {
264            logDiagnostics(diagnostics);
265        }
266        @SuppressWarnings("unchecked")
267        var result = (Collection<T>) List.of(
268            JavadocDirectory.of(project(), destDir));
269        return result;
270    }
271
272    private List<String> evaluateOptions(Path destDir) {
273        if (options().contains("-d")) {
274            new ConfigurationException().from(this).message("Specifying the"
275                + " destination directory with options() is not allowed.");
276        }
277        List<String> allOptions = new ArrayList<>(options());
278        allOptions.addAll(List.of("-d", destDir.toString()));
279
280        // Handle classpath and module-path
281        var ccResources = Resources.of(CodeContributionsType).addAll(
282            projects.stream().flatMap(p -> p.resources(
283                of(CodeContributionType).using(Consume, Reveal, Expose))));
284        var useModulePath = effectiveModuleMode() == ModuleMode.MODULE;
285        var cpe = ccResources.stream()
286            .filter(cc -> !useModulePath || !cc.isModular()).toList();
287        var mpe = ccResources.stream()
288            .filter(cc -> useModulePath && cc.isModular()).toList();
289        logger.atFinest().log(
290            "Generating in %s with classpath %s and module-path %s", project(),
291            lazy(() -> cpe.stream().map(Resource::toString).toList()),
292            lazy(() -> mpe.stream().map(Resource::toString).toList()));
293        if (!cpe.isEmpty()) {
294            var classpath = cpe.stream().map(e -> e.toPath().toString())
295                .collect(Collectors.joining(File.pathSeparator));
296            allOptions.addAll(List.of("-cp", classpath));
297        }
298        if (!mpe.isEmpty()) {
299            var modulePath = mpe.stream().map(e -> e.toPath().toString())
300                .collect(Collectors.joining(File.pathSeparator));
301            allOptions.addAll(List.of("--module-path", modulePath));
302        }
303
304        // Handle taglets
305        var tagletPath = tagletPath();
306        if (!tagletPath.isEmpty()) {
307            allOptions.addAll(List.of("-tagletpath", tagletPath));
308        }
309        for (var taglet : taglets) {
310            allOptions.addAll(List.of("-taglet", taglet));
311        }
312        return allOptions;
313    }
314
315    private String tagletPath() {
316        return tagletpath.stream().<Path> mapMulti((e, consumer) -> {
317            if (e instanceof ClassTree classTree) {
318                consumer.accept(classTree.root());
319            } else if (e instanceof JarFile jarFile) {
320                consumer.accept(jarFile.path());
321            }
322        }).map(Path::toString).collect(Collectors.joining(File.pathSeparator));
323    }
324}