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