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