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}