001/* 002 * JDrupes Builder 003 * Copyright (C) 2025, 2026 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 com.google.common.flogger.FluentLogger; 022import io.vavr.control.Option; 023import io.vavr.control.Try; 024import java.io.IOException; 025import java.nio.file.Files; 026import java.nio.file.Path; 027import static java.nio.file.StandardOpenOption.*; 028import java.util.Arrays; 029import java.util.Comparator; 030import java.util.Map; 031import java.util.Map.Entry; 032import java.util.concurrent.ConcurrentHashMap; 033import java.util.function.Supplier; 034import java.util.jar.Attributes; 035import java.util.jar.Attributes.Name; 036import java.util.jar.JarEntry; 037import java.util.jar.JarOutputStream; 038import java.util.jar.Manifest; 039import java.util.stream.Collectors; 040import java.util.stream.Stream; 041import java.util.stream.StreamSupport; 042import org.jdrupes.builder.api.BuildException; 043import org.jdrupes.builder.api.ConfigurationException; 044import org.jdrupes.builder.api.FileResource; 045import org.jdrupes.builder.api.FileTree; 046import org.jdrupes.builder.api.IOResource; 047import org.jdrupes.builder.api.Project; 048import static org.jdrupes.builder.api.Project.Properties.*; 049import org.jdrupes.builder.api.Resource; 050import org.jdrupes.builder.api.ResourceProviderSpi; 051import org.jdrupes.builder.api.ResourceRequest; 052import org.jdrupes.builder.api.ResourceType; 053import static org.jdrupes.builder.api.ResourceType.*; 054import org.jdrupes.builder.api.Resources; 055import org.jdrupes.builder.core.AbstractGenerator; 056import org.jdrupes.builder.core.StreamCollector; 057 058/// A general purpose generator for jars. All contents must be added 059/// explicitly using one of the `add*` methods. 060/// 061@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.TooManyMethods" }) 062public class JarBuilder extends AbstractGenerator { 063 064 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 065 private final ResourceType<? extends JarFile> jarType; 066 private Supplier<Path> destination 067 = () -> project().buildDirectory().resolve("libs"); 068 private Supplier<String> jarName 069 = () -> project().name() + "-" + project().get(Version) + ".jar"; 070 private final StreamCollector<Entry<Name, String>> attributes 071 = StreamCollector.cached(); 072 private final StreamCollector< 073 Map.Entry<Path, ? extends IOResource>> entryStreams 074 = StreamCollector.cached(); 075 private final StreamCollector<FileTree<?>> fileTrees 076 = StreamCollector.cached(); 077 078 /// Initializes a new library generator. 079 /// 080 /// @param project the project 081 /// @param jarType the type of jar that the generator generates 082 /// 083 public JarBuilder(Project project, 084 ResourceType<? extends JarFile> jarType) { 085 super(project); 086 this.jarType = jarType; 087 } 088 089 @Override 090 public JarBuilder name(String name) { 091 rename(name); 092 return this; 093 } 094 095 /// Returns the destination directory. Defaults to sub directory 096 /// `libs` in the project's build directory 097 /// (see [Project#buildDirectory]). 098 /// 099 /// @return the destination 100 /// 101 public Path destination() { 102 return destination.get(); 103 } 104 105 /// Sets the destination directory. The [Path] is resolved against 106 /// the project's build directory (see [Project#buildDirectory]). 107 /// 108 /// @param destination the new destination 109 /// @return the jar generator 110 /// 111 public JarBuilder destination(Path destination) { 112 this.destination 113 = () -> project().buildDirectory().resolve(destination); 114 return this; 115 } 116 117 /// Sets the destination directory. 118 /// 119 /// @param destination the new destination 120 /// @return the jar generator 121 /// 122 public JarBuilder destination(Supplier<Path> destination) { 123 this.destination = destination; 124 return this; 125 } 126 127 /// Returns the name of the generated jar file. Defaults to 128 /// the project's name followed by its version and `.jar`. 129 /// 130 /// @return the string 131 /// 132 public String jarName() { 133 return jarName.get(); 134 } 135 136 /// Sets the supplier for obtaining the name of the generated jar file 137 /// in [ResourceProviderSpi#provide]. 138 /// 139 /// @param jarName the jar name 140 /// @return the jar generator 141 /// 142 public JarBuilder jarName(Supplier<String> jarName) { 143 this.jarName = jarName; 144 return this; 145 } 146 147 /// Sets the name of the generated jar file. 148 /// 149 /// @param jarName the jar name 150 /// @return the jar generator 151 /// 152 public JarBuilder jarName(String jarName) { 153 return jarName(() -> jarName); 154 } 155 156 /// Add the given attributes to the manifest. 157 /// 158 /// @param attributes the attributes 159 /// @return the library generator 160 /// 161 public JarBuilder addAttributeValues( 162 Stream<Map.Entry<Attributes.Name, String>> attributes) { 163 this.attributes.add(attributes); 164 return this; 165 } 166 167 /// Add the given attributes to the manifest. 168 /// 169 /// @param attributes the attributes 170 /// @return the library generator 171 /// 172 @SuppressWarnings("PMD.LooseCoupling") 173 public JarBuilder 174 addManifestAttributes(Stream<ManifestAttributes> attributes) { 175 addAttributeValues( 176 attributes.map(a -> a.entrySet().stream()).flatMap(s -> s) 177 .map(e -> Map.entry((Attributes.Name) e.getKey(), 178 (String) e.getValue()))); 179 return this; 180 } 181 182 /// Add the given attributes to the manifest. 183 /// 184 /// @param attributes the attributes 185 /// @return the library generator 186 /// 187 @SafeVarargs 188 public final JarBuilder 189 attributes(Map.Entry<Attributes.Name, String>... attributes) { 190 this.attributes.add(Arrays.stream(attributes)); 191 return this; 192 } 193 194 /// Adds single resources to the jar. Each entry is added to the 195 /// jar as entry with the name passed in the key attribute of the 196 /// `Map.Entry` with the content from the [IOResource] in the 197 /// value attribute. 198 /// 199 /// @param entries the entries 200 /// @return the jar generator 201 /// 202 public JarBuilder addEntries( 203 Stream<? extends Map.Entry<Path, ? extends IOResource>> entries) { 204 entryStreams.add(entries); 205 return this; 206 } 207 208 /// Adds the given [FileTree]s. Each file in the tree will be added 209 /// as an entry using its relative path in the tree as name. 210 /// 211 /// @param trees the trees 212 /// @return the jar generator 213 /// 214 public JarBuilder addTrees(Stream<? extends FileTree<?>> trees) { 215 fileTrees.add(trees); 216 return this; 217 } 218 219 /// Convenience method for adding entries, see [#addTrees(Stream)]. 220 /// 221 /// @param trees the trees 222 /// @return the jar generator 223 /// 224 public JarBuilder add(FileTree<?>... trees) { 225 addTrees(Arrays.stream(trees)); 226 return this; 227 } 228 229 /// Adds the file tree with the given prefix for each entry. 230 /// 231 /// @param prefix the prefix 232 /// @param tree the tree 233 /// @return the jar builder 234 /// 235 public JarBuilder add(Path prefix, FileTree<?> tree) { 236 entryStreams.add(tree.entries().map(e -> Map.entry(prefix.resolve(e), 237 FileResource.of(tree.root().resolve(e))))); 238 return this; 239 } 240 241 /// For each file tree, add its entries with the given prefix. 242 /// 243 /// @param prefix the prefix 244 /// @param trees the trees 245 /// @return the jar builder 246 /// 247 public JarBuilder add(Path prefix, Stream<? extends FileTree<?>> trees) { 248 entryStreams.add( 249 trees.flatMap(t -> t.entries().map(e -> Map.entry(prefix.resolve(e), 250 FileResource.of(t.root().resolve(e)))))); 251 return this; 252 } 253 254 /// Convenience method for adding a single entry, see [#addEntries(Stream)]. 255 /// 256 /// @param path the path 257 /// @param resource the resource 258 /// @return the jar generator 259 /// 260 public JarBuilder add(Path path, IOResource resource) { 261 addEntries(Map.of(path, resource).entrySet().stream()); 262 return this; 263 } 264 265 /// Builds the jar. 266 /// 267 /// @param jarResource the jar resource 268 /// 269 @SuppressWarnings("PMD.ConfusingTernary") 270 protected void buildJar(JarFile jarResource) { 271 // Collect entries for jar from all sources 272 var contents = new ConcurrentHashMap<Path, Resources<IOResource>>(); 273 collectContents(contents); 274 resolveDuplicates(contents); 275 276 // Check if rebuild needed (requires manifest check). 277 var oldManifest = Option.of(jarResource) 278 .filter(jar -> jar.path().toFile().canRead()) 279 .flatMap(jr -> Try.withResources( 280 () -> new java.util.jar.JarFile(jr.path().toFile())) 281 .of(jar -> Try.of(jar::getManifest).toOption() 282 .flatMap(Option::of)) 283 .toOption().flatMap(m -> m)) 284 .getOrElse(Manifest::new); 285 Manifest manifest = createManifest(); 286 if (!manifest.equals(oldManifest)) { 287 logger.atFine().log("Rebuilding %s, manifest changed", jarName()); 288 } else { 289 // manifest unchanged, check timestamps 290 var newer = contents.values().stream() 291 .map(r -> r.stream().findFirst().stream()).flatMap(s -> s) 292 .filter(r -> r.isNewerThan(jarResource)).findAny(); 293 if (newer.isEmpty()) { 294 logger.atFine().log("Existing %s is up to date.", jarName()); 295 return; 296 } 297 logger.atFine().log( 298 "Rebuilding %s, is older than %s", jarName(), newer.get()); 299 } 300 writeJar(jarResource, contents, manifest); 301 } 302 303 private Manifest createManifest() { 304 Manifest manifest = new Manifest(); 305 @SuppressWarnings("PMD.LooseCoupling") 306 Attributes attributes = manifest.getMainAttributes(); 307 attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); 308 this.attributes.stream().sorted(Map.Entry.comparingByKey( 309 Comparator.comparing(Attributes.Name::toString))) 310 .forEach(e -> attributes.put(e.getKey(), e.getValue())); 311 return manifest; 312 } 313 314 private void writeJar(JarFile jarResource, 315 Map<Path, Resources<IOResource>> contents, Manifest manifest) { 316 // Write jar file 317 logger.atInfo().log("Building %s in %s", jarName(), project().name()); 318 try { 319 // Allow continued use of existing jar if open (POSIX only) 320 Files.deleteIfExists(jarResource.path()); 321 } catch (IOException e) { // NOPMD 322 } 323 try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream( 324 jarResource.path(), CREATE, TRUNCATE_EXISTING), manifest)) { 325 for (var entry : contents.entrySet()) { 326 if (entry.getValue().isEmpty()) { 327 continue; 328 } 329 var entryName 330 = StreamSupport.stream(entry.getKey().spliterator(), false) 331 .map(Path::toString).collect(Collectors.joining("/")); 332 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 333 JarEntry jarEntry = new JarEntry(entryName); 334 var ioResource = entry.getValue().stream().findFirst(); 335 if (ioResource.isEmpty()) { 336 logger.atWarning() 337 .log(jarEntry + " has no associated data"); 338 continue; 339 } 340 jarEntry.setTime(ioResource.get().asOf().get().toEpochMilli()); 341 jos.putNextEntry(jarEntry); 342 try (var input = entry.getValue().stream().findFirst().get() 343 .inputStream()) { 344 input.transferTo(jos); 345 } 346 } 347 348 } catch (IOException e) { 349 throw new BuildException().from(this).cause(e); 350 } 351 } 352 353 /// Add the contents from the added streams as preliminary jar 354 /// entries. Must be overridden by derived classes that define 355 /// additional ways to provide contents. The overriding method 356 /// must invoke `super.collectEntries(...)`. 357 /// 358 /// @param contents the preliminary contents 359 /// 360 protected void collectContents(Map<Path, Resources<IOResource>> contents) { 361 entryStreams.stream().forEach(entry -> { 362 contents.computeIfAbsent(entry.getKey(), 363 _ -> Resources.with(IOResource.class)) 364 .add(entry.getValue()); 365 }); 366 fileTrees.stream().parallel() 367 .forEach(t -> collect(contents, t)); 368 } 369 370 /// Adds the resources from the given file tree to the given contents. 371 /// May be used by derived classes while collecting contents for 372 /// the jar. 373 /// 374 /// @param collected the preliminary contents 375 /// @param fileTree the file tree 376 /// 377 protected void collect(Map<Path, Resources<IOResource>> collected, 378 FileTree<?> fileTree) { 379 var root = fileTree.root(); 380 fileTree.stream().forEach(file -> { 381 var relPath = root.relativize(file.path()); 382 collected.computeIfAbsent(relPath, 383 _ -> Resources.with(IOResource.class)).add(file); 384 }); 385 } 386 387 /// Resolve duplicates. The default implementation outputs a warning 388 /// and skips the duplicate entry. 389 /// 390 /// @param entries the entries 391 /// 392 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", 393 "PMD.UselessPureMethodCall" }) 394 protected void resolveDuplicates( 395 Map<Path, Resources<IOResource>> entries) { 396 entries.entrySet().parallelStream().forEach(item -> { 397 var resources = item.getValue(); 398 if (resources.stream().count() == 1) { 399 return; 400 } 401 var entryName = item.getKey(); 402 resources.stream().reduce((a, b) -> { 403 logger.atWarning().log( 404 "Entry %s from %s duplicates entry from %s and is skipped.", 405 entryName, a, b); 406 return a; 407 }); 408 }); 409 } 410 411 @Override 412 @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked" }) 413 protected <T extends Resource> Stream<T> 414 doProvide(ResourceRequest<T> requested) { 415 if (!requested.accepts(jarType) 416 && !requested.accepts(CleanlinessType)) { 417 return Stream.empty(); 418 } 419 420 // Prepare jar file 421 var destDir = destination(); 422 if (!destDir.toFile().exists()) { 423 if (!destDir.toFile().mkdirs()) { 424 throw new ConfigurationException().from(this) 425 .message("Cannot create directory: %s", destDir); 426 } 427 } 428 var jarResource = JarFile.of(jarType, destDir.resolve(jarName())); 429 430 // Maybe only delete 431 if (requested.accepts(CleanlinessType)) { 432 jarResource.cleanup(); 433 return Stream.empty(); 434 } 435 436 // Upgrade to most specific type to avoid duplicate generation 437 if (!requested.type().equals(jarType)) { 438 return (Stream<T>) context().resources(this, project().of(jarType)); 439 } 440 441 buildJar(jarResource); 442 return Stream.of((T) jarResource); 443 } 444}