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.distribution; 020 021import com.google.common.flogger.FluentLogger; 022import io.github.azagniotov.matcher.AntPathMatcher; 023import java.io.IOException; 024import java.nio.file.Path; 025import java.util.ArrayList; 026import java.util.Arrays; 027import java.util.Collection; 028import java.util.Collections; 029import java.util.List; 030import java.util.Map; 031import java.util.Objects; 032import java.util.concurrent.ConcurrentHashMap; 033import java.util.function.Predicate; 034import java.util.jar.JarEntry; 035import org.jdrupes.builder.api.BuildException; 036import org.jdrupes.builder.api.ConfigurationException; 037import org.jdrupes.builder.api.FileTree; 038import org.jdrupes.builder.api.Generator; 039import org.jdrupes.builder.api.InputResource; 040import static org.jdrupes.builder.api.Intent.*; 041import org.jdrupes.builder.api.Project; 042import org.jdrupes.builder.api.Resource; 043import org.jdrupes.builder.api.ResourceRequest; 044import org.jdrupes.builder.api.ResourceType; 045import static org.jdrupes.builder.api.ResourceType.*; 046import org.jdrupes.builder.api.Resources; 047import org.jdrupes.builder.java.AppJarFile; 048import org.jdrupes.builder.java.ClassTree; 049import org.jdrupes.builder.java.ClasspathElement; 050import org.jdrupes.builder.java.JarFile; 051import org.jdrupes.builder.java.JarFileEntry; 052import org.jdrupes.builder.java.JavaResourceTree; 053import static org.jdrupes.builder.java.JavaTypes.*; 054import org.jdrupes.builder.java.LibraryBuilder; 055import org.jdrupes.builder.java.LibraryJarFile; 056import org.jdrupes.builder.java.ServicesEntryResource; 057import org.jdrupes.builder.mvnrepo.MvnRepoJarFile; 058import org.jdrupes.builder.mvnrepo.MvnRepoLookup; 059import org.jdrupes.builder.mvnrepo.MvnRepoResource; 060 061/// A [Generator] for uber jars. 062/// 063/// Depending on the request, the generator provides one of two resource 064/// types. 065/// 066/// 1. A [JarFile]. This type of resource is also returned if a more 067/// general [ResourceType] such as [ClasspathElement] is requested. 068/// 069/// 2. An [AppJarFile]. When this special [JarFile] type is requested, the 070/// generator requires a main class to be configured. 071/// 072/// The generator takes the following approach: 073/// 074/// * Request resources of type [ClasspathElement] with all intents from 075/// the added providers. Add the class and resource trees to the sources 076/// to be processed. JAR files are handled differently depending on their 077/// origin. The content of JAR files that are not retrieved from a Maven 078/// repository is added to the sources to be processed. For 079/// [MvnRepoJarFile]s (i.e. JAR files from a Maven repository) only the 080/// [MvnRepoResource] reference is collected. 081/// * Use all [MvnRepoResource]s obtained in the previous step for a 082/// dependency resolution. Add the content from the resulting JAR 083/// files to the sources to be processed. 084/// * Add resources from the sources to the uber jar. Merge the files in 085/// `META-INF/services/` that have the same name by concatenating them. 086/// * Filter out any other duplicate files under `META-INF`. 087/// These files often contain information related to the origin jar 088/// that is not applicable to the uber jar. 089/// * Filter out any module-info.class entries. 090/// 091/// The resource type of the uber jar builder's output is one 092/// of the resource types of its inputs, because uber jars can also be used 093/// as [ClasspathElement]. Therefore, you cannot add a uber jar builder 094/// to the project like this: 095/// ```java 096/// generator(UberJarBuilder::new).addFrom(this); // Circular dependency 097/// ``` 098/// 099/// This would add the project as provider and thus make the uber jar 100/// builder's result a uber jar builder's source (via 101/// [Project.resources][Project#resources]). Instead use the following 102/// approach: 103/// ```java 104/// generator(UberJarGenerator::new) 105/// .addFrom(providers().select(Forward, Expose, Supply)); 106/// ``` 107/// 108/// This requests the same providers from the project as 109/// [Project.resources][Project#resources] would, but allows the uber jar 110/// builder's [addFrom] method to filter out the uber jar 111/// builder itself from the providers. The given intents can 112/// vary depending on the requirements. 113/// 114/// If the generated uber jar should not be visible to the project's other 115/// generators, you can also add it like this: 116/// ```java 117/// dependency(new UberJarGenerator(this).addFrom( 118/// providers(EnumSet.of(Forward, Expose, Supply))), Intent.Forward) 119/// ``` 120/// 121/// In most cases, the simplest solution is to generate the uber jar 122/// in a separate project, typically the parent project. This cleanly 123/// separates the generation of class and resource trees and library jars 124/// from the generation of the uber jar. 125/// 126public class UberJarBuilder extends LibraryBuilder { 127 128 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 129 @SuppressWarnings("PMD.FieldNamingConventions") 130 private static final AntPathMatcher pathMatcher 131 = new AntPathMatcher.Builder().build(); 132 private Map<Path, java.util.jar.JarFile> openJars = Map.of(); 133 private Predicate<Resource> resourceFilter = _ -> true; 134 private final List<String> ignoredDuplicates = new ArrayList<>(); 135 136 /// Instantiates a new uber jar generator. 137 /// 138 /// @param project the project 139 /// 140 public UberJarBuilder(Project project) { 141 super(Objects.requireNonNull(project)); 142 } 143 144 @Override 145 public UberJarBuilder name(String name) { 146 rename(Objects.requireNonNull(name)); 147 return this; 148 } 149 150 /// Ignore duplicates matching the given glob patterns when merging. 151 /// 152 /// @param patterns the patterns 153 /// @return the uber jar builder 154 /// 155 public UberJarBuilder ignoreDuplicates(String... patterns) { 156 ignoredDuplicates.addAll(Arrays.asList(patterns)); 157 return this; 158 } 159 160 @Override 161 protected void collectFromProviders( 162 Map<Path, Resources<InputResource>> contents) { 163 Resources<MvnRepoResource> repoRefs 164 = Resources.of(new ResourceType<>() {}); 165 openJars = new ConcurrentHashMap<>(); 166 contentProviders().stream().filter(p -> !p.equals(this)) 167 .map(p -> p.resources(of(ClasspathElementType).usingAll())) 168 // Terminate to trigger all future stream evaluations before 169 // starting to process the results. Then collect in parallel 170 .toList().stream().flatMap(s -> s).toList().parallelStream() 171 .filter(resourceFilter::test).forEach(cpe -> { 172 if (cpe instanceof FileTree<?> fileTree) { 173 logger.atFine().log("Adding from FileTree: %s", fileTree); 174 collect(contents, fileTree); 175 return; 176 } 177 if (cpe instanceof MvnRepoJarFile repoFile) { 178 // Resolve JAR files from Maven repositories, see below 179 repoRefs.add(repoFile.reference()); 180 return; 181 } 182 if (cpe instanceof JarFile jarFile) { 183 logger.atFine().log("Adding from library: %s", jarFile); 184 addJarFile(contents, jarFile, openJars); 185 } 186 }); 187 188 // Jar files from Maven repositories must be resolved before 189 // they can be added to the uber jar to avoid duplicates. 190 var lookup = new MvnRepoLookup(); 191 lookup.resolve(repoRefs.stream()); 192 project().context().resources(lookup, of(ClasspathElementType) 193 .using(Consume, Reveal, Supply, Expose, Forward)) 194 .parallel().filter(resourceFilter::test).forEach(cpe -> { 195 if (cpe instanceof MvnRepoJarFile jarFile) { 196 logger.atFine().log("Adding from Maven repository: %s (%s)", 197 jarFile, jarFile.reference()); 198 addJarFile(contents, jarFile, openJars); 199 } 200 }); 201 } 202 203 /// Apply the given filter to the resources obtained from the provider. 204 /// The resources can be [ClasspathElement]s or [MvnRepoResource]s. 205 /// This may be required to avoid warnings about duplicates if e.g. 206 /// a sub-project provides generated resources both as 207 /// [ClassTree]/[JavaResourceTree] and as [LibraryJarFile]. 208 /// 209 /// @param filter the filter. Returns `true` for resources to be 210 /// included. 211 /// @return the uber jar generator 212 /// 213 public UberJarBuilder resourceFilter(Predicate<Resource> filter) { 214 resourceFilter = Objects.requireNonNull(filter); 215 return this; 216 } 217 218 private void addJarFile(Map<Path, Resources<InputResource>> entries, 219 JarFile jarFile, Map<Path, java.util.jar.JarFile> openJars) { 220 @SuppressWarnings({ "PMD.CloseResource" }) 221 java.util.jar.JarFile jar 222 = openJars.computeIfAbsent(jarFile.path(), _ -> { 223 try { 224 return new java.util.jar.JarFile(jarFile.path().toFile()); 225 } catch (IOException e) { 226 throw new BuildException().from(this).cause(e); 227 } 228 }); 229 jar.stream().filter(Predicate.not(JarEntry::isDirectory)) 230 .filter(e -> !Path.of(e.getName()) 231 .endsWith(Path.of("module-info.class"))) 232 .filter(e -> { 233 // Filter top-level entries in META-INF/ 234 var segs = Path.of(e.getRealName()).iterator(); 235 if (segs.next().equals(Path.of("META-INF"))) { 236 segs.next(); 237 return segs.hasNext(); 238 } 239 return true; 240 }).forEach(e -> { 241 var relPath = Path.of(e.getRealName()); 242 entries.computeIfAbsent(relPath, 243 _ -> Resources.with(InputResource.class)) 244 .add(new JarFileEntry(jar, e)); 245 }); 246 } 247 248 @SuppressWarnings({ "PMD.UselessPureMethodCall", 249 "PMD.AvoidLiteralsInIfCondition" }) 250 @Override 251 protected void 252 resolveDuplicates(Map<Path, Resources<InputResource>> entries) { 253 entries.entrySet().parallelStream().forEach(item -> { 254 var entryName = item.getKey(); 255 var candidates = item.getValue(); 256 if (candidates.stream().count() == 1) { 257 return; 258 } 259 if (entryName.startsWith("META-INF/services")) { 260 var combined = new ServicesEntryResource(); 261 candidates.stream().forEach(service -> { 262 try { 263 combined.add(service); 264 } catch (IOException e) { 265 throw new BuildException().from(this).cause(e); 266 } 267 }); 268 candidates.clear(); 269 candidates.add(combined); 270 return; 271 } 272 if (entryName.startsWith("META-INF")) { 273 candidates.clear(); 274 } 275 if (ignoredDuplicates.stream() 276 .map(p -> pathMatcher.isMatch(p, entryName.toString())) 277 .filter(Boolean::booleanValue).findFirst().isPresent()) { 278 return; 279 } 280 candidates.stream().reduce((a, b) -> { 281 logger.atWarning().log("%s: Entry %s from %s duplicates" 282 + " entry from %s and is skipped.", this, entryName, a, b); 283 return a; 284 }); 285 }); 286 } 287 288 @Override 289 @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked", 290 "PMD.CloseResource", "PMD.UseTryWithResources", 291 "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" }) 292 protected <T extends Resource> Collection<T> 293 doProvide(ResourceRequest<T> request) { 294 if (!request.accepts(AppJarFileType) 295 && !request.accepts(CleanlinessType)) { 296 return Collections.emptyList(); 297 } 298 299 // Maybe only delete 300 if (request.accepts(CleanlinessType)) { 301 destination().resolve(jarName()).toFile().delete(); 302 return Collections.emptyList(); 303 } 304 305 // Make sure mainClass is set for app jar 306 if (request.isFor(AppJarFileType) && mainClass() == null) { 307 throw new ConfigurationException().from(this) 308 .message("Main class must be set for %s", name()); 309 } 310 311 // Upgrade to most specific type to avoid duplicate generation 312 if (mainClass() != null && !request.type().equals(AppJarFileType)) { 313 return (Collection<T>) context() 314 .resources(this, project().of(AppJarFileType)).toList(); 315 } 316 if (mainClass() == null && !request.type().equals(JarFileType)) { 317 return (Collection<T>) context() 318 .resources(this, project().of(JarFileType)).toList(); 319 } 320 321 // Prepare jar file 322 var destDir = destination(); 323 if (!destDir.toFile().exists()) { 324 if (!destDir.toFile().mkdirs()) { 325 throw new ConfigurationException().from(this) 326 .message("Cannot create directory " + destDir); 327 } 328 } 329 var jarResource = request.isFor(AppJarFileType) 330 ? AppJarFile.of(destDir.resolve(jarName())) 331 : LibraryJarFile.of(destDir.resolve(jarName())); 332 try { 333 buildJar(jarResource); 334 } finally { 335 // buidJar indirectly calls collectFromProviders which opens 336 // resources that are used in buildJar. Close them now. 337 for (var jarFile : openJars.values()) { 338 try { 339 jarFile.close(); 340 } catch (IOException e) { // NOPMD 341 // Ignore, just trying to be nice. 342 } 343 } 344 } 345 return List.of((T) jarResource); 346 } 347}