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