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