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.uberjar;
020
021import com.google.common.flogger.FluentLogger;
022import java.io.IOException;
023import java.nio.file.FileSystems;
024import java.nio.file.Path;
025import java.nio.file.PathMatcher;
026import java.util.ArrayList;
027import java.util.Arrays;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031import java.util.concurrent.ConcurrentHashMap;
032import java.util.function.Predicate;
033import java.util.jar.JarEntry;
034import java.util.stream.Stream;
035import org.jdrupes.builder.api.BuildException;
036import org.jdrupes.builder.api.FileTree;
037import org.jdrupes.builder.api.Generator;
038import org.jdrupes.builder.api.IOResource;
039import org.jdrupes.builder.api.Intent;
040import static org.jdrupes.builder.api.Intent.*;
041import org.jdrupes.builder.api.Project;
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.java.AppJarFile;
048import org.jdrupes.builder.java.ClassTree;
049import org.jdrupes.builder.java.ClasspathElement;
050import org.jdrupes.builder.java.JarFile;
051import org.jdrupes.builder.java.JarFileEntry;
052import org.jdrupes.builder.java.JavaResourceTree;
053import static org.jdrupes.builder.java.JavaTypes.*;
054import org.jdrupes.builder.java.LibraryBuilder;
055import org.jdrupes.builder.java.LibraryJarFile;
056import org.jdrupes.builder.java.ServicesEntryResource;
057import org.jdrupes.builder.mvnrepo.MvnRepoJarFile;
058import org.jdrupes.builder.mvnrepo.MvnRepoLookup;
059import org.jdrupes.builder.mvnrepo.MvnRepoResource;
060import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
061
062/// A [Generator] for uber jars.
063///
064/// Depending on the request, the generator provides two types of resources.
065/// 
066/// 1. A [JarFile]. This type of resource is also returned if a more
067///    general [ResourceType] such as [ClasspathElement] is requested.
068///
069/// 2. An [AppJarFile]. When requesting this special jar type, the
070///    generator checks if a main class is specified.
071///
072/// The generator takes the following approach:
073/// 
074///   * Request `Resources<ClasspathElement>` from the providers. Add the
075///     resource trees and the jar files to the sources to be processed.
076///     Ignore jar files from maven repositories (instances of
077///     [MvnRepoJarFile]).
078///   * Request all [MvnRepoResource]s from the providers and use them for
079///     a dependency resolution. Add the jar files from the dependency
080///     resolution to the resources to be processed.
081///   * Add resources from the sources to the uber jar. Merge the files in
082///     `META-INF/services/` that have the same name by concatenating them.
083///   * Filter out any other duplicate direct child files of `META-INF`.
084///     These files often contain information related to the origin jar
085///     that is not applicable to the uber jar.
086///   * Filter out any module-info.class entries.
087///
088/// Note that the [UberJarBuilder] does deliberately not request the
089/// [ClasspathElement]s as `RuntimeResources` because this may return
090/// resources twice if a project uses another project as runtime
091/// dependency (i.e. with [Intent#Consume]. If this rule causes entries
092/// to be missing, simply add them explicitly.  
093/// 
094/// The resource type of the uber jar generator's output is one
095/// of the resource types of its inputs, because uber jars can also be used
096/// as [ClasspathElement]. Therefore, if you want to create an uber jar
097/// from all resources provided by a project, you must not add the
098/// generator to the project like this:
099/// ```java
100///     generator(UberJarGenerator::new).add(this); // Circular dependency
101/// ```
102///
103/// This would add the project as provider and thus make the uber jar
104/// generator as supplier to the project its own provider (via
105/// [Project.resources][Project#resources]). Rather, you have to use this
106/// slightly more complicated approach to adding providers to the uber
107/// jar generator:
108/// ```java
109///     generator(UberJarGenerator::new)
110///         .addAll(providers().select(Forward, Expose, Supply));
111/// ```
112/// This requests the same providers from the project as 
113/// [Project.resources][Project#resources] does, but allows the uber jar
114/// generator's [addFrom] method to filter out the uber jar
115/// generator itself from the providers. The given intents can
116/// vary depending on the requirements.
117///
118/// If you don't want the generated uber jar to be available to other
119/// generators of your project, you can also add it to a project like this:
120/// ```java
121///     dependency(new UberJarGenerator(this)
122///         .from(providers(EnumSet.of(Forward, Expose, Supply))), Intent.Forward)
123/// ```
124///
125/// Of course, the easiest thing to do is separate the generation of
126/// class trees or library jars from the generation of the uber jar by
127/// generating the uber jar in a project of its own. Often the root
128/// project can be used for this purpose.  
129///
130public class UberJarBuilder extends LibraryBuilder {
131
132    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
133    private Map<Path, java.util.jar.JarFile> openJars = Map.of();
134    private Predicate<Resource> resourceFilter = _ -> true;
135    private final List<PathMatcher> ignoredDuplicates = new ArrayList<>();
136
137    /// Instantiates a new uber jar generator.
138    ///
139    /// @param project the project
140    ///
141    public UberJarBuilder(Project project) {
142        super(Objects.requireNonNull(project));
143    }
144
145    @Override
146    public UberJarBuilder name(String name) {
147        rename(Objects.requireNonNull(name));
148        return this;
149    }
150
151    /// Ignore duplicates matching the given glob patterns when merging.
152    ///
153    /// @param patterns the patterns
154    /// @return the uber jar builder
155    ///
156    public UberJarBuilder ignoreDuplicates(String... patterns) {
157        Arrays.asList(patterns).forEach(
158            p -> ignoredDuplicates.add(FileSystems.getDefault()
159                .getPathMatcher("glob:" + p)));
160        return this;
161    }
162
163    @Override
164    protected void collectFromProviders(
165            Map<Path, Resources<IOResource>> contents) {
166        openJars = new ConcurrentHashMap<>();
167        providers().stream().map(p -> p.resources(
168            of(ClasspathElementType).using(Supply, Expose)))
169            .flatMap(s -> s).parallel()
170            .filter(resourceFilter::test).forEach(cpe -> {
171                if (cpe instanceof FileTree<?> fileTree) {
172                    collect(contents, fileTree);
173                } else if (cpe instanceof JarFile jarFile
174                    // Ignore jar files from maven repositories, see below
175                    && !(jarFile instanceof MvnRepoJarFile)) {
176                    addJarFile(contents, jarFile, openJars);
177                }
178            });
179
180        // Jar files from maven repositories must be resolved before
181        // they can be added to the uber jar, i.e. they must be added
182        // with their transitive dependencies.
183        var lookup = new MvnRepoLookup();
184        lookup.resolve(providers().stream().map(
185            p -> p.resources(of(MvnRepoDependencyType).usingAll()))
186            .flatMap(s -> s));
187        project().context().resources(lookup, of(ClasspathElementType)
188            .using(Consume, Reveal, Supply, Expose, Forward))
189            .parallel().filter(resourceFilter::test).forEach(cpe -> {
190                if (cpe instanceof MvnRepoJarFile jarFile) {
191                    addJarFile(contents, jarFile, openJars);
192                }
193            });
194    }
195
196    /// Apply the given filter to the resources obtained from the provider.
197    /// The resources can be [ClasspathElement]s or [MvnRepoResource]s.
198    /// This may be required to avoid warnings about duplicates if e.g.
199    /// a sub-project provides generated resources both as
200    /// [ClassTree]/[JavaResourceTree] and as [LibraryJarFile].
201    ///
202    /// @param filter the filter. Returns `true` for resources to be
203    /// included.
204    /// @return the uber jar generator
205    ///
206    public UberJarBuilder resourceFilter(Predicate<Resource> filter) {
207        resourceFilter = Objects.requireNonNull(filter);
208        return this;
209    }
210
211    private void addJarFile(Map<Path, Resources<IOResource>> entries,
212            JarFile jarFile, Map<Path, java.util.jar.JarFile> openJars) {
213        @SuppressWarnings({ "PMD.CloseResource" })
214        java.util.jar.JarFile jar
215            = openJars.computeIfAbsent(jarFile.path(), _ -> {
216                try {
217                    return new java.util.jar.JarFile(jarFile.path().toFile());
218                } catch (IOException e) {
219                    throw new BuildException("Cannot open resource %s: %s",
220                        jarFile, e).from(this).cause(e);
221                }
222            });
223        jar.stream().filter(Predicate.not(JarEntry::isDirectory))
224            .filter(e -> !Path.of(e.getName())
225                .endsWith(Path.of("module-info.class")))
226            .filter(e -> {
227                // Filter top-level entries in META-INF/
228                var segs = Path.of(e.getRealName()).iterator();
229                if (segs.next().equals(Path.of("META-INF"))) {
230                    segs.next();
231                    return segs.hasNext();
232                }
233                return true;
234            }).forEach(e -> {
235                var relPath = Path.of(e.getRealName());
236                entries.computeIfAbsent(relPath,
237                    _ -> project().newResource(IOResourcesType))
238                    .add(new JarFileEntry(jar, e));
239            });
240    }
241
242    @SuppressWarnings({ "PMD.UselessPureMethodCall",
243        "PMD.AvoidLiteralsInIfCondition" })
244    @Override
245    protected void resolveDuplicates(Map<Path, Resources<IOResource>> entries) {
246        entries.entrySet().parallelStream().forEach(item -> {
247            var entryName = item.getKey();
248            var candidates = item.getValue();
249            if (candidates.stream().count() == 1) {
250                return;
251            }
252            if (entryName.startsWith("META-INF/services")) {
253                var combined = new ServicesEntryResource();
254                candidates.stream().forEach(service -> {
255                    try {
256                        combined.add(service);
257                    } catch (IOException e) {
258                        throw new BuildException("Cannot read %s: %s",
259                            service, e).from(this).cause(e);
260                    }
261                });
262                candidates.clear();
263                candidates.add(combined);
264                return;
265            }
266            if (entryName.startsWith("META-INF")) {
267                candidates.clear();
268            }
269            if (ignoredDuplicates.stream().map(m -> m.matches(entryName))
270                .filter(Boolean::booleanValue).findFirst().isPresent()) {
271                return;
272            }
273            candidates.stream().reduce((a, b) -> {
274                logger.atWarning().log("Entry %s from %s duplicates"
275                    + " entry from %s and is skipped.", entryName, a, b);
276                return a;
277            });
278        });
279    }
280
281    @Override
282    @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked",
283        "PMD.CloseResource", "PMD.UseTryWithResources",
284        "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" })
285    protected <T extends Resource> Stream<T>
286            doProvide(ResourceRequest<T> requested) {
287        if (!requested.accepts(AppJarFileType)
288            && !requested.accepts(CleanlinessType)) {
289            return Stream.empty();
290        }
291
292        // Maybe only delete
293        if (requested.accepts(CleanlinessType)) {
294            destination().resolve(jarName()).toFile().delete();
295            return Stream.empty();
296        }
297
298        // Make sure mainClass is set for app jar
299        if (requested.requires(AppJarFileType) && mainClass() == null) {
300            throw new BuildException("Main class must be set for %s", name())
301                .from(this);
302        }
303
304        // Upgrade to most specific type to avoid duplicate generation
305        if (mainClass() != null && !requested.type().equals(AppJarFileType)) {
306            return (Stream<T>) context()
307                .resources(this, project().of(AppJarFileType));
308        }
309        if (mainClass() == null && !requested.type().equals(JarFileType)) {
310            return (Stream<T>) context()
311                .resources(this, project().of(JarFileType));
312        }
313
314        // Prepare jar file
315        var destDir = destination();
316        if (!destDir.toFile().exists()) {
317            if (!destDir.toFile().mkdirs()) {
318                throw new BuildException("Cannot create directory " + destDir);
319            }
320        }
321        var jarResource = requested.requires(AppJarFileType)
322            ? project().newResource(AppJarFileType,
323                destDir.resolve(jarName()))
324            : project().newResource(LibraryJarFileType,
325                destDir.resolve(jarName()));
326        try {
327            buildJar(jarResource);
328        } finally {
329            // buidJar indirectly calls collectFromProviders which opens
330            // resources that are used in buildJar. Close them now.
331            for (var jarFile : openJars.values()) {
332                try {
333                    jarFile.close();
334                } catch (IOException e) { // NOPMD
335                    // Ignore, just trying to be nice.
336                }
337            }
338        }
339        return Stream.of((T) jarResource);
340    }
341}