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