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