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