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