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 collect(contents, fileTree); 174 return; 175 } 176 if (cpe instanceof JarFile jarFile) { 177 if (jarFile instanceof MvnRepoJarFile repoFile) { 178 // Resolve JAR files from Maven repositories, see below 179 repoRefs.add(repoFile.reference()); 180 } else { 181 addJarFile(contents, jarFile, openJars); 182 } 183 } 184 }); 185 186 // Jar files from Maven repositories must be resolved before 187 // they can be added to the uber jar to avoid duplicates. 188 var lookup = new MvnRepoLookup(); 189 lookup.resolve(repoRefs.stream()); 190 project().context().resources(lookup, of(ClasspathElementType) 191 .using(Consume, Reveal, Supply, Expose, Forward)) 192 .parallel().filter(resourceFilter::test).forEach(cpe -> { 193 if (cpe instanceof MvnRepoJarFile jarFile) { 194 addJarFile(contents, jarFile, openJars); 195 } 196 }); 197 } 198 199 /// Apply the given filter to the resources obtained from the provider. 200 /// The resources can be [ClasspathElement]s or [MvnRepoResource]s. 201 /// This may be required to avoid warnings about duplicates if e.g. 202 /// a sub-project provides generated resources both as 203 /// [ClassTree]/[JavaResourceTree] and as [LibraryJarFile]. 204 /// 205 /// @param filter the filter. Returns `true` for resources to be 206 /// included. 207 /// @return the uber jar generator 208 /// 209 public UberJarBuilder resourceFilter(Predicate<Resource> filter) { 210 resourceFilter = Objects.requireNonNull(filter); 211 return this; 212 } 213 214 private void addJarFile(Map<Path, Resources<InputResource>> entries, 215 JarFile jarFile, Map<Path, java.util.jar.JarFile> openJars) { 216 @SuppressWarnings({ "PMD.CloseResource" }) 217 java.util.jar.JarFile jar 218 = openJars.computeIfAbsent(jarFile.path(), _ -> { 219 try { 220 return new java.util.jar.JarFile(jarFile.path().toFile()); 221 } catch (IOException e) { 222 throw new BuildException().from(this).cause(e); 223 } 224 }); 225 jar.stream().filter(Predicate.not(JarEntry::isDirectory)) 226 .filter(e -> !Path.of(e.getName()) 227 .endsWith(Path.of("module-info.class"))) 228 .filter(e -> { 229 // Filter top-level entries in META-INF/ 230 var segs = Path.of(e.getRealName()).iterator(); 231 if (segs.next().equals(Path.of("META-INF"))) { 232 segs.next(); 233 return segs.hasNext(); 234 } 235 return true; 236 }).forEach(e -> { 237 var relPath = Path.of(e.getRealName()); 238 entries.computeIfAbsent(relPath, 239 _ -> Resources.with(InputResource.class)) 240 .add(new JarFileEntry(jar, e)); 241 }); 242 } 243 244 @SuppressWarnings({ "PMD.UselessPureMethodCall", 245 "PMD.AvoidLiteralsInIfCondition" }) 246 @Override 247 protected void 248 resolveDuplicates(Map<Path, Resources<InputResource>> entries) { 249 entries.entrySet().parallelStream().forEach(item -> { 250 var entryName = item.getKey(); 251 var candidates = item.getValue(); 252 if (candidates.stream().count() == 1) { 253 return; 254 } 255 if (entryName.startsWith("META-INF/services")) { 256 var combined = new ServicesEntryResource(); 257 candidates.stream().forEach(service -> { 258 try { 259 combined.add(service); 260 } catch (IOException e) { 261 throw new BuildException().from(this).cause(e); 262 } 263 }); 264 candidates.clear(); 265 candidates.add(combined); 266 return; 267 } 268 if (entryName.startsWith("META-INF")) { 269 candidates.clear(); 270 } 271 if (ignoredDuplicates.stream() 272 .map(p -> pathMatcher.isMatch(p, entryName.toString())) 273 .filter(Boolean::booleanValue).findFirst().isPresent()) { 274 return; 275 } 276 candidates.stream().reduce((a, b) -> { 277 logger.atWarning().log("Entry %s from %s duplicates" 278 + " entry from %s and is skipped.", entryName, a, b); 279 return a; 280 }); 281 }); 282 } 283 284 @Override 285 @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked", 286 "PMD.CloseResource", "PMD.UseTryWithResources", 287 "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" }) 288 protected <T extends Resource> Collection<T> 289 doProvide(ResourceRequest<T> request) { 290 if (!request.accepts(AppJarFileType) 291 && !request.accepts(CleanlinessType)) { 292 return Collections.emptyList(); 293 } 294 295 // Maybe only delete 296 if (request.accepts(CleanlinessType)) { 297 destination().resolve(jarName()).toFile().delete(); 298 return Collections.emptyList(); 299 } 300 301 // Make sure mainClass is set for app jar 302 if (request.isFor(AppJarFileType) && mainClass() == null) { 303 throw new ConfigurationException().from(this) 304 .message("Main class must be set for %s", name()); 305 } 306 307 // Upgrade to most specific type to avoid duplicate generation 308 if (mainClass() != null && !request.type().equals(AppJarFileType)) { 309 return (Collection<T>) context() 310 .resources(this, project().of(AppJarFileType)).toList(); 311 } 312 if (mainClass() == null && !request.type().equals(JarFileType)) { 313 return (Collection<T>) context() 314 .resources(this, project().of(JarFileType)).toList(); 315 } 316 317 // Prepare jar file 318 var destDir = destination(); 319 if (!destDir.toFile().exists()) { 320 if (!destDir.toFile().mkdirs()) { 321 throw new ConfigurationException().from(this) 322 .message("Cannot create directory " + destDir); 323 } 324 } 325 var jarResource = request.isFor(AppJarFileType) 326 ? AppJarFile.of(destDir.resolve(jarName())) 327 : LibraryJarFile.of(destDir.resolve(jarName())); 328 try { 329 buildJar(jarResource); 330 } finally { 331 // buidJar indirectly calls collectFromProviders which opens 332 // resources that are used in buildJar. Close them now. 333 for (var jarFile : openJars.values()) { 334 try { 335 jarFile.close(); 336 } catch (IOException e) { // NOPMD 337 // Ignore, just trying to be nice. 338 } 339 } 340 } 341 return List.of((T) jarResource); 342 } 343}