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