001/*
002 * JDrupes Builder
003 * Copyright (C) 2025, 2026 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.distribution;
020
021import com.google.common.flogger.FluentLogger;
022import io.github.azagniotov.matcher.AntPathMatcher;
023import java.io.IOException;
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.Map;
031import java.util.Objects;
032import java.util.concurrent.ConcurrentHashMap;
033import java.util.function.Predicate;
034import java.util.jar.JarEntry;
035import org.jdrupes.builder.api.BuildException;
036import org.jdrupes.builder.api.ConfigurationException;
037import org.jdrupes.builder.api.FileTree;
038import org.jdrupes.builder.api.Generator;
039import org.jdrupes.builder.api.InputResource;
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;
060
061/// A [Generator] for uber jars.
062///
063/// Depending on the request, the generator provides one of two resource
064/// types.
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 this special [JarFile] type is requested, the
070///    generator requires a main class to be configured.
071///
072/// The generator takes the following approach:
073/// 
074///   * Request resources of type [ClasspathElement] with all intents from
075///     the added providers. Add the class and resource trees to the sources
076///     to be processed. JAR files are handled differently depending on their
077///     origin. The content of JAR files that are not retrieved from a Maven
078///     repository is added to the sources to be processed. For
079///     [MvnRepoJarFile]s (i.e. JAR files from a Maven repository) only the
080///     [MvnRepoResource] reference is collected.
081///   * Use all [MvnRepoResource]s obtained in the previous step for a
082///     dependency resolution. Add the content from the resulting JAR
083///     files to the sources to be processed.
084///   * Add resources from the sources to the uber jar. Merge the files in
085///     `META-INF/services/` that have the same name by concatenating them.
086///   * Filter out any other duplicate files under `META-INF`.
087///     These files often contain information related to the origin jar
088///     that is not applicable to the uber jar.
089///   * Filter out any module-info.class entries.
090///
091/// The resource type of the uber jar builder's output is one
092/// of the resource types of its inputs, because uber jars can also be used
093/// as [ClasspathElement]. Therefore, you cannot add a uber jar builder
094/// to the project like this:
095/// ```java
096///     generator(UberJarBuilder::new).addFrom(this); // Circular dependency
097/// ```
098///
099/// This would add the project as provider and thus make the uber jar
100/// builder's result a uber jar builder's source (via
101/// [Project.resources][Project#resources]). Instead use the following
102/// approach:
103/// ```java
104///     generator(UberJarGenerator::new)
105///         .addFrom(providers().select(Forward, Expose, Supply));
106/// ```
107/// 
108/// This requests the same providers from the project as 
109/// [Project.resources][Project#resources] would, but allows the uber jar
110/// builder's [addFrom] method to filter out the uber jar
111/// builder itself from the providers. The given intents can
112/// vary depending on the requirements.
113///
114/// If the generated uber jar should not be visible to the project's other
115/// generators, you can also add it like this:
116/// ```java
117///     dependency(new UberJarGenerator(this).addFrom(
118///         providers(EnumSet.of(Forward, Expose, Supply))), Intent.Forward)
119/// ```
120///
121/// In most cases, the simplest solution is to generate the uber jar
122/// in a separate project, typically the parent project. This cleanly
123/// separates the generation of class and resource trees and library jars
124/// from the generation of the uber jar.
125///
126public class UberJarBuilder extends LibraryBuilder {
127
128    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
129    @SuppressWarnings("PMD.FieldNamingConventions")
130    private static final AntPathMatcher pathMatcher
131        = new AntPathMatcher.Builder().build();
132    private Map<Path, java.util.jar.JarFile> openJars = Map.of();
133    private Predicate<Resource> resourceFilter = _ -> true;
134    private final List<String> ignoredDuplicates = new ArrayList<>();
135
136    /// Instantiates a new uber jar generator.
137    ///
138    /// @param project the project
139    ///
140    public UberJarBuilder(Project project) {
141        super(Objects.requireNonNull(project));
142    }
143
144    @Override
145    public UberJarBuilder name(String name) {
146        rename(Objects.requireNonNull(name));
147        return this;
148    }
149
150    /// Ignore duplicates matching the given glob patterns when merging.
151    ///
152    /// @param patterns the patterns
153    /// @return the uber jar builder
154    ///
155    public UberJarBuilder ignoreDuplicates(String... patterns) {
156        ignoredDuplicates.addAll(Arrays.asList(patterns));
157        return this;
158    }
159
160    @Override
161    protected void collectFromProviders(
162            Map<Path, Resources<InputResource>> contents) {
163        Resources<MvnRepoResource> repoRefs
164            = Resources.of(new ResourceType<>() {});
165        openJars = new ConcurrentHashMap<>();
166        contentProviders().stream().filter(p -> !p.equals(this))
167            .map(p -> p.resources(of(ClasspathElementType).usingAll()))
168            // Terminate to trigger all future stream evaluations before
169            // starting to process the results. Then collect in parallel
170            .toList().stream().flatMap(s -> s).toList().parallelStream()
171            .filter(resourceFilter::test).forEach(cpe -> {
172                if (cpe instanceof FileTree<?> fileTree) {
173                    logger.atFine().log("Adding from FileTree: %s", fileTree);
174                    collect(contents, fileTree);
175                    return;
176                }
177                if (cpe instanceof MvnRepoJarFile repoFile) {
178                    // Resolve JAR files from Maven repositories, see below
179                    repoRefs.add(repoFile.reference());
180                    return;
181                }
182                if (cpe instanceof JarFile jarFile) {
183                    logger.atFine().log("Adding from library: %s", jarFile);
184                    addJarFile(contents, jarFile, openJars);
185                }
186            });
187
188        // Jar files from Maven repositories must be resolved before
189        // they can be added to the uber jar to avoid duplicates.
190        var lookup = new MvnRepoLookup();
191        lookup.resolve(repoRefs.stream());
192        project().context().resources(lookup, of(ClasspathElementType)
193            .using(Consume, Reveal, Supply, Expose, Forward))
194            .parallel().filter(resourceFilter::test).forEach(cpe -> {
195                if (cpe instanceof MvnRepoJarFile jarFile) {
196                    logger.atFine().log("Adding from Maven repository: %s (%s)",
197                        jarFile, jarFile.reference());
198                    addJarFile(contents, jarFile, openJars);
199                }
200            });
201    }
202
203    /// Apply the given filter to the resources obtained from the provider.
204    /// The resources can be [ClasspathElement]s or [MvnRepoResource]s.
205    /// This may be required to avoid warnings about duplicates if e.g.
206    /// a sub-project provides generated resources both as
207    /// [ClassTree]/[JavaResourceTree] and as [LibraryJarFile].
208    ///
209    /// @param filter the filter. Returns `true` for resources to be
210    /// included.
211    /// @return the uber jar generator
212    ///
213    public UberJarBuilder resourceFilter(Predicate<Resource> filter) {
214        resourceFilter = Objects.requireNonNull(filter);
215        return this;
216    }
217
218    private void addJarFile(Map<Path, Resources<InputResource>> entries,
219            JarFile jarFile, Map<Path, java.util.jar.JarFile> openJars) {
220        @SuppressWarnings({ "PMD.CloseResource" })
221        java.util.jar.JarFile jar
222            = openJars.computeIfAbsent(jarFile.path(), _ -> {
223                try {
224                    return new java.util.jar.JarFile(jarFile.path().toFile());
225                } catch (IOException e) {
226                    throw new BuildException().from(this).cause(e);
227                }
228            });
229        jar.stream().filter(Predicate.not(JarEntry::isDirectory))
230            .filter(e -> !Path.of(e.getName())
231                .endsWith(Path.of("module-info.class")))
232            .filter(e -> {
233                // Filter top-level entries in META-INF/
234                var segs = Path.of(e.getRealName()).iterator();
235                if (segs.next().equals(Path.of("META-INF"))) {
236                    segs.next();
237                    return segs.hasNext();
238                }
239                return true;
240            }).forEach(e -> {
241                var relPath = Path.of(e.getRealName());
242                entries.computeIfAbsent(relPath,
243                    _ -> Resources.with(InputResource.class))
244                    .add(new JarFileEntry(jar, e));
245            });
246    }
247
248    @SuppressWarnings({ "PMD.UselessPureMethodCall",
249        "PMD.AvoidLiteralsInIfCondition" })
250    @Override
251    protected void
252            resolveDuplicates(Map<Path, Resources<InputResource>> entries) {
253        entries.entrySet().parallelStream().forEach(item -> {
254            var entryName = item.getKey();
255            var candidates = item.getValue();
256            if (candidates.stream().count() == 1) {
257                return;
258            }
259            if (entryName.startsWith("META-INF/services")) {
260                var combined = new ServicesEntryResource();
261                candidates.stream().forEach(service -> {
262                    try {
263                        combined.add(service);
264                    } catch (IOException e) {
265                        throw new BuildException().from(this).cause(e);
266                    }
267                });
268                candidates.clear();
269                candidates.add(combined);
270                return;
271            }
272            if (entryName.startsWith("META-INF")) {
273                candidates.clear();
274            }
275            if (ignoredDuplicates.stream()
276                .map(p -> pathMatcher.isMatch(p, entryName.toString()))
277                .filter(Boolean::booleanValue).findFirst().isPresent()) {
278                return;
279            }
280            candidates.stream().reduce((a, b) -> {
281                logger.atWarning().log("%s: Entry %s from %s duplicates"
282                    + " entry from %s and is skipped.", this, entryName, a, b);
283                return a;
284            });
285        });
286    }
287
288    @Override
289    @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked",
290        "PMD.CloseResource", "PMD.UseTryWithResources",
291        "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" })
292    protected <T extends Resource> Collection<T>
293            doProvide(ResourceRequest<T> request) {
294        if (!request.accepts(AppJarFileType)
295            && !request.accepts(CleanlinessType)) {
296            return Collections.emptyList();
297        }
298
299        // Maybe only delete
300        if (request.accepts(CleanlinessType)) {
301            destination().resolve(jarName()).toFile().delete();
302            return Collections.emptyList();
303        }
304
305        // Make sure mainClass is set for app jar
306        if (request.isFor(AppJarFileType) && mainClass() == null) {
307            throw new ConfigurationException().from(this)
308                .message("Main class must be set for %s", name());
309        }
310
311        // Upgrade to most specific type to avoid duplicate generation
312        if (mainClass() != null && !request.type().equals(AppJarFileType)) {
313            return (Collection<T>) context()
314                .resources(this, project().of(AppJarFileType)).toList();
315        }
316        if (mainClass() == null && !request.type().equals(JarFileType)) {
317            return (Collection<T>) context()
318                .resources(this, project().of(JarFileType)).toList();
319        }
320
321        // Prepare jar file
322        var destDir = destination();
323        if (!destDir.toFile().exists()) {
324            if (!destDir.toFile().mkdirs()) {
325                throw new ConfigurationException().from(this)
326                    .message("Cannot create directory " + destDir);
327            }
328        }
329        var jarResource = request.isFor(AppJarFileType)
330            ? AppJarFile.of(destDir.resolve(jarName()))
331            : LibraryJarFile.of(destDir.resolve(jarName()));
332        try {
333            buildJar(jarResource);
334        } finally {
335            // buidJar indirectly calls collectFromProviders which opens
336            // resources that are used in buildJar. Close them now.
337            for (var jarFile : openJars.values()) {
338                try {
339                    jarFile.close();
340                } catch (IOException e) { // NOPMD
341                    // Ignore, just trying to be nice.
342                }
343            }
344        }
345        return List.of((T) jarResource);
346    }
347}