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                    collect(contents, fileTree);
174                    return;
175                }
176                if (cpe instanceof JarFile jarFile) {
177                    if (jarFile instanceof MvnRepoJarFile repoFile) {
178                        // Resolve JAR files from Maven repositories, see below
179                        repoRefs.add(repoFile.reference());
180                    } else {
181                        addJarFile(contents, jarFile, openJars);
182                    }
183                }
184            });
185
186        // Jar files from Maven repositories must be resolved before
187        // they can be added to the uber jar to avoid duplicates.
188        var lookup = new MvnRepoLookup();
189        lookup.resolve(repoRefs.stream());
190        project().context().resources(lookup, of(ClasspathElementType)
191            .using(Consume, Reveal, Supply, Expose, Forward))
192            .parallel().filter(resourceFilter::test).forEach(cpe -> {
193                if (cpe instanceof MvnRepoJarFile jarFile) {
194                    addJarFile(contents, jarFile, openJars);
195                }
196            });
197    }
198
199    /// Apply the given filter to the resources obtained from the provider.
200    /// The resources can be [ClasspathElement]s or [MvnRepoResource]s.
201    /// This may be required to avoid warnings about duplicates if e.g.
202    /// a sub-project provides generated resources both as
203    /// [ClassTree]/[JavaResourceTree] and as [LibraryJarFile].
204    ///
205    /// @param filter the filter. Returns `true` for resources to be
206    /// included.
207    /// @return the uber jar generator
208    ///
209    public UberJarBuilder resourceFilter(Predicate<Resource> filter) {
210        resourceFilter = Objects.requireNonNull(filter);
211        return this;
212    }
213
214    private void addJarFile(Map<Path, Resources<InputResource>> entries,
215            JarFile jarFile, Map<Path, java.util.jar.JarFile> openJars) {
216        @SuppressWarnings({ "PMD.CloseResource" })
217        java.util.jar.JarFile jar
218            = openJars.computeIfAbsent(jarFile.path(), _ -> {
219                try {
220                    return new java.util.jar.JarFile(jarFile.path().toFile());
221                } catch (IOException e) {
222                    throw new BuildException().from(this).cause(e);
223                }
224            });
225        jar.stream().filter(Predicate.not(JarEntry::isDirectory))
226            .filter(e -> !Path.of(e.getName())
227                .endsWith(Path.of("module-info.class")))
228            .filter(e -> {
229                // Filter top-level entries in META-INF/
230                var segs = Path.of(e.getRealName()).iterator();
231                if (segs.next().equals(Path.of("META-INF"))) {
232                    segs.next();
233                    return segs.hasNext();
234                }
235                return true;
236            }).forEach(e -> {
237                var relPath = Path.of(e.getRealName());
238                entries.computeIfAbsent(relPath,
239                    _ -> Resources.with(InputResource.class))
240                    .add(new JarFileEntry(jar, e));
241            });
242    }
243
244    @SuppressWarnings({ "PMD.UselessPureMethodCall",
245        "PMD.AvoidLiteralsInIfCondition" })
246    @Override
247    protected void
248            resolveDuplicates(Map<Path, Resources<InputResource>> entries) {
249        entries.entrySet().parallelStream().forEach(item -> {
250            var entryName = item.getKey();
251            var candidates = item.getValue();
252            if (candidates.stream().count() == 1) {
253                return;
254            }
255            if (entryName.startsWith("META-INF/services")) {
256                var combined = new ServicesEntryResource();
257                candidates.stream().forEach(service -> {
258                    try {
259                        combined.add(service);
260                    } catch (IOException e) {
261                        throw new BuildException().from(this).cause(e);
262                    }
263                });
264                candidates.clear();
265                candidates.add(combined);
266                return;
267            }
268            if (entryName.startsWith("META-INF")) {
269                candidates.clear();
270            }
271            if (ignoredDuplicates.stream()
272                .map(p -> pathMatcher.isMatch(p, entryName.toString()))
273                .filter(Boolean::booleanValue).findFirst().isPresent()) {
274                return;
275            }
276            candidates.stream().reduce((a, b) -> {
277                logger.atWarning().log("Entry %s from %s duplicates"
278                    + " entry from %s and is skipped.", entryName, a, b);
279                return a;
280            });
281        });
282    }
283
284    @Override
285    @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked",
286        "PMD.CloseResource", "PMD.UseTryWithResources",
287        "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" })
288    protected <T extends Resource> Collection<T>
289            doProvide(ResourceRequest<T> request) {
290        if (!request.accepts(AppJarFileType)
291            && !request.accepts(CleanlinessType)) {
292            return Collections.emptyList();
293        }
294
295        // Maybe only delete
296        if (request.accepts(CleanlinessType)) {
297            destination().resolve(jarName()).toFile().delete();
298            return Collections.emptyList();
299        }
300
301        // Make sure mainClass is set for app jar
302        if (request.isFor(AppJarFileType) && mainClass() == null) {
303            throw new ConfigurationException().from(this)
304                .message("Main class must be set for %s", name());
305        }
306
307        // Upgrade to most specific type to avoid duplicate generation
308        if (mainClass() != null && !request.type().equals(AppJarFileType)) {
309            return (Collection<T>) context()
310                .resources(this, project().of(AppJarFileType)).toList();
311        }
312        if (mainClass() == null && !request.type().equals(JarFileType)) {
313            return (Collection<T>) context()
314                .resources(this, project().of(JarFileType)).toList();
315        }
316
317        // Prepare jar file
318        var destDir = destination();
319        if (!destDir.toFile().exists()) {
320            if (!destDir.toFile().mkdirs()) {
321                throw new ConfigurationException().from(this)
322                    .message("Cannot create directory " + destDir);
323            }
324        }
325        var jarResource = request.isFor(AppJarFileType)
326            ? AppJarFile.of(destDir.resolve(jarName()))
327            : LibraryJarFile.of(destDir.resolve(jarName()));
328        try {
329            buildJar(jarResource);
330        } finally {
331            // buidJar indirectly calls collectFromProviders which opens
332            // resources that are used in buildJar. Close them now.
333            for (var jarFile : openJars.values()) {
334                try {
335                    jarFile.close();
336                } catch (IOException e) { // NOPMD
337                    // Ignore, just trying to be nice.
338                }
339            }
340        }
341        return List.of((T) jarResource);
342    }
343}