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.java;
020
021import java.io.IOException;
022import java.nio.file.Files;
023import java.nio.file.Path;
024import static java.nio.file.StandardOpenOption.*;
025import java.util.Arrays;
026import java.util.Map;
027import java.util.Map.Entry;
028import java.util.concurrent.ConcurrentHashMap;
029import java.util.function.Supplier;
030import java.util.jar.Attributes;
031import java.util.jar.Attributes.Name;
032import java.util.jar.JarEntry;
033import java.util.jar.JarOutputStream;
034import java.util.jar.Manifest;
035import java.util.stream.Collectors;
036import java.util.stream.Stream;
037import java.util.stream.StreamSupport;
038import org.jdrupes.builder.api.BuildException;
039import org.jdrupes.builder.api.FileTree;
040import org.jdrupes.builder.api.IOResource;
041import org.jdrupes.builder.api.Project;
042import static org.jdrupes.builder.api.Project.Properties.*;
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.core.AbstractGenerator;
049import org.jdrupes.builder.core.StreamCollector;
050
051/// A general purpose generator for jars. All contents must be added
052/// explicitly using [#add(Entry...)] or [#add(FileTree...)].
053///
054@SuppressWarnings("PMD.CouplingBetweenObjects")
055public class JarGenerator extends AbstractGenerator {
056
057    private final ResourceType<? extends JarFile> jarType;
058    private Supplier<Path> destination
059        = () -> project().buildDirectory().resolve("libs");
060    private Supplier<String> jarName
061        = () -> project().name() + "-" + project().get(Version) + ".jar";
062    private final StreamCollector<Entry<Name, String>> attributes
063        = StreamCollector.cached();
064    private final StreamCollector<
065            Map.Entry<Path, ? extends IOResource>> entryStreams
066                = StreamCollector.cached();
067    private final StreamCollector<FileTree<?>> fileTrees
068        = StreamCollector.cached();
069
070    /// Instantiates a new library generator.
071    ///
072    /// @param project the project
073    /// @param jarType the type of jar that the generator generates
074    ///
075    public JarGenerator(Project project,
076            ResourceType<? extends JarFile> jarType) {
077        super(project);
078        this.jarType = jarType;
079    }
080
081    /// Returns the destination directory. Defaults to sub directory
082    /// `libs` in the project's build directory
083    /// (see [Project#buildDirectory]).
084    ///
085    /// @return the destination
086    ///
087    public Path destination() {
088        return destination.get();
089    }
090
091    /// Sets the destination directory. The [Path] is resolved against
092    /// the project's build directory (see [Project#buildDirectory]).
093    ///
094    /// @param destination the new destination
095    /// @return the jar generator
096    ///
097    public JarGenerator destination(Path destination) {
098        this.destination
099            = () -> project().buildDirectory().resolve(destination);
100        return this;
101    }
102
103    /// Sets the destination directory.
104    ///
105    /// @param destination the new destination
106    /// @return the jar generator
107    ///
108    public JarGenerator destination(Supplier<Path> destination) {
109        this.destination = destination;
110        return this;
111    }
112
113    /// Returns the name of the generated jar file. Defaults to
114    /// the project's name followed by its version and `.jar`.
115    ///
116    /// @return the string
117    ///
118    public String jarName() {
119        return jarName.get();
120    }
121
122    /// Sets the supplier for obtaining the name of the generated jar file
123    /// in [#provide].
124    ///
125    /// @param jarName the jar name
126    /// @return the jar generator
127    ///
128    public JarGenerator jarName(Supplier<String> jarName) {
129        this.jarName = jarName;
130        return this;
131    }
132
133    /// Sets the name of the generated jar file.
134    ///
135    /// @param jarName the jar name
136    /// @return the jar generator
137    ///
138    public JarGenerator jarName(String jarName) {
139        return jarName(() -> jarName);
140    }
141
142    /// Add the given attributes to the manifest.
143    ///
144    /// @param attributes the attributes
145    /// @return the library generator
146    ///
147    public JarGenerator
148            attributes(Stream<Map.Entry<Attributes.Name, String>> attributes) {
149        this.attributes.add(attributes);
150        return this;
151    }
152
153    /// Add the given attributes to the manifest.
154    ///
155    /// @param attributes the attributes
156    /// @return the library generator
157    ///
158    @SafeVarargs
159    public final JarGenerator
160            attributes(Map.Entry<Attributes.Name, String>... attributes) {
161        this.attributes.add(Arrays.stream(attributes));
162        return this;
163    }
164
165    /// Adds single resources to the jar. Each entry is added to the
166    /// jar as entry with the name passed in the key attribute of the
167    /// `Map.Entry` with the content from the [IOResource] in the
168    /// value attribute.
169    ///
170    /// @param entries the entries
171    /// @return the jar generator
172    ///
173    public JarGenerator addEntries(
174            Stream<? extends Map.Entry<Path, ? extends IOResource>> entries) {
175        entryStreams.add(entries);
176        return this;
177    }
178
179    /// Adds the given [FileTree]s. Each file in the tree will be added
180    /// as an entry using its relative path in the tree as name.  
181    ///
182    /// @param trees the trees
183    /// @return the jar generator
184    ///
185    public JarGenerator addTrees(Stream<? extends FileTree<?>> trees) {
186        fileTrees.add(trees);
187        return this;
188    }
189
190    /// Convenience method for adding entries, see [#addTrees(Stream)].
191    ///
192    /// @param trees the trees
193    /// @return the jar generator
194    ///
195    public JarGenerator add(FileTree<?>... trees) {
196        addTrees(Arrays.stream(trees));
197        return this;
198    }
199
200    /// Convenience method for adding a single entry, see [#addEntries(Stream)].
201    ///
202    /// @param entries the entry
203    /// @return the jar generator
204    ///
205    public JarGenerator add(@SuppressWarnings("unchecked") Map.Entry<Path,
206            ? extends IOResource>... entries) {
207        addEntries(Arrays.stream(entries));
208        return this;
209    }
210
211    /// Builds the jar.
212    ///
213    /// @param jarResource the jar resource
214    ///
215    protected void buildJar(JarFile jarResource) {
216        // Collect entries for jar from all sources
217        var contents = new ConcurrentHashMap<Path, Resources<IOResource>>();
218        collectContents(contents);
219        resolveDuplicates(contents);
220
221        // Check if rebuild needed.
222        var newer = contents.values().stream()
223            .map(r -> r.stream().findFirst().stream()).flatMap(s -> s)
224            .filter(r -> r.asOf().isAfter(jarResource.asOf())).findAny();
225        if (newer.isEmpty()) {
226            log.fine(() -> "Existing " + jarName() + " is up to date.");
227            return;
228        }
229        log.fine(
230            () -> "Rebuilding " + jarName() + ", is older than " + newer.get());
231
232        // Write jar file
233        log.info(() -> "Building " + jarName() + " in " + project().name());
234        Manifest manifest = new Manifest();
235        @SuppressWarnings("PMD.LooseCoupling")
236        Attributes attributes = manifest.getMainAttributes();
237        attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0");
238        this.attributes.stream()
239            .forEach(e -> attributes.put(e.getKey(), e.getValue()));
240        try {
241            // Allow continued use of existing jar if open (POSIX only)
242            Files.deleteIfExists(jarResource.path());
243        } catch (IOException e) { // NOPMD
244        }
245        try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(
246            jarResource.path(), CREATE, TRUNCATE_EXISTING), manifest)) {
247            for (var entry : contents.entrySet()) {
248                if (entry.getValue().isEmpty()) {
249                    continue;
250                }
251                var entryName
252                    = StreamSupport.stream(entry.getKey().spliterator(), false)
253                        .map(Path::toString).collect(Collectors.joining("/"));
254                @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
255                JarEntry jarEntry = new JarEntry(entryName);
256                jarEntry.setTime(entry.getValue().stream().findFirst().get()
257                    .asOf().toEpochMilli());
258                jos.putNextEntry(jarEntry);
259                try (var input = entry.getValue().stream().findFirst().get()
260                    .inputStream()) {
261                    input.transferTo(jos);
262                }
263            }
264
265        } catch (IOException e) {
266            throw new BuildException(e);
267        }
268    }
269
270    /// Add the contents from the added streams as preliminary jar
271    /// entries. Must be overridden by derived classes that define
272    /// additional ways to provide contents. The overriding method
273    /// must invoke `super.collectEntries(...)`.
274    ///
275    /// @param contents the preliminary contents
276    ///
277    protected void collectContents(Map<Path, Resources<IOResource>> contents) {
278        entryStreams.stream().forEach(entry -> {
279            contents.computeIfAbsent(entry.getKey(),
280                _ -> project().newResource(IOResourcesType))
281                .add(entry.getValue());
282        });
283        fileTrees.stream().parallel()
284            .forEach(t -> collect(contents, t));
285    }
286
287    /// Adds the resources from the given file tree to the given contents.
288    /// May be used by derived classes while collecting contents for
289    /// the jar.
290    ///
291    /// @param collected the preliminary contents
292    /// @param fileTree the file tree
293    ///
294    protected void collect(Map<Path, Resources<IOResource>> collected,
295            FileTree<?> fileTree) {
296        var root = fileTree.root();
297        fileTree.stream().forEach(file -> {
298            var relPath = root.relativize(file.path());
299            collected.computeIfAbsent(relPath,
300                _ -> project().newResource(IOResourcesType)).add(file);
301        });
302    }
303
304    /// Resolve duplicates. The default implementation outputs a warning
305    /// and skips the duplicate entry. 
306    ///
307    /// @param entries the entries
308    ///
309    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
310        "PMD.UselessPureMethodCall" })
311    protected void resolveDuplicates(
312            Map<Path, Resources<IOResource>> entries) {
313        entries.entrySet().parallelStream().forEach(item -> {
314            var resources = item.getValue();
315            if (resources.stream().count() == 1) {
316                return;
317            }
318            var entryName = item.getKey();
319            resources.stream().reduce((a, b) -> {
320                log.warning(() -> "Entry " + entryName + " from " + a
321                    + " duplicates entry from " + b + " and is skipped.");
322                return a;
323            });
324        });
325    }
326
327    @Override
328    @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked" })
329    protected <T extends Resource> Stream<T>
330            doProvide(ResourceRequest<T> requested) {
331        if (!requested.collects(jarType)
332            && !requested.collects(CleanlinessType)) {
333            return Stream.empty();
334        }
335
336        // Prepare jar file
337        var destDir = destination();
338        if (!destDir.toFile().exists()) {
339            if (!destDir.toFile().mkdirs()) {
340                throw new BuildException("Cannot create directory " + destDir);
341            }
342        }
343        var jarResource = project().newResource(jarType,
344            destDir.resolve(jarName()));
345
346        // Maybe only delete
347        if (requested.collects(CleanlinessType)) {
348            jarResource.delete();
349            return Stream.empty();
350        }
351
352        buildJar(jarResource);
353        return Stream.of((T) jarResource);
354    }
355}