001/* 002 * JDrupes Builder 003 * Copyright (C) 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 java.nio.file.Path; 023import java.util.Collection; 024import java.util.Collections; 025import java.util.List; 026import java.util.Map; 027import java.util.Objects; 028import java.util.function.Consumer; 029import java.util.function.Supplier; 030import java.util.stream.Collectors; 031import java.util.stream.Stream; 032import static org.jdrupes.builder.api.CoreProperties.*; 033import org.jdrupes.builder.api.Cleanliness; 034import org.jdrupes.builder.api.ConfigurationException; 035import org.jdrupes.builder.api.FileResource; 036import org.jdrupes.builder.api.Project; 037import org.jdrupes.builder.api.Resource; 038import org.jdrupes.builder.api.ResourceProviderSpi; 039import org.jdrupes.builder.api.ResourceRequest; 040import org.jdrupes.builder.api.ResourceType; 041import static org.jdrupes.builder.api.ResourceType.*; 042import org.jdrupes.builder.api.Resources; 043import org.jdrupes.builder.api.TarFile; 044import org.jdrupes.builder.api.ZipFile; 045import org.jdrupes.builder.core.AbstractGenerator; 046import org.jdrupes.builder.core.StreamCollector; 047import static org.jdrupes.builder.java.JavaTypes.*; 048import org.jdrupes.builder.java.internal.ApplicationConfigurationData; 049import org.jdrupes.builder.java.internal.TarDistributionBuilder; 050import org.jdrupes.builder.java.internal.ZipDistributionBuilder; 051 052/// The [ApplicationBuilder] generates resources of type 053/// [ApplicationZipFile] or [ApplicationTarFile]. 054/// 055/// Both resource types are runnable application distributions built from 056/// a set of classpath resources and a generated start script that launches 057/// the application. 058/// 059/// The application can be configured using methods that control: 060/// 061/// * the [output directory][#destination(Path)] for the generated 062/// distribution, 063/// * the [base name][#distributionBaseName(Supplier)] of the generated 064/// archive file, 065/// * the executable (start script) [name][#executableName(String)], 066/// * the [main class][#mainClassName(String)] to execute (mandatory), 067/// * and the [JVM options][#applicationJvmOpts(Consumer)] required 068/// by the application and included in the generated start script. 069/// 070/// Method [#add(Stream)] is used to specify the classpath resources to 071/// be included in the generated distribution and to be added to the 072/// classpath when running the application. 073/// 074/// A request for [Cleanliness] removes any generated distribution 075/// archives from the configured destination directory. 076/// 077/// The [ApplicationBuilder] avoids duplicate libraries in the distribution 078/// by deduplicating [JarFile]s based on their file path. If the same jar 079/// file is added multiple times, only one copy will be included in the 080/// generated distribution. 081/// 082public class ApplicationBuilder extends AbstractGenerator { 083 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 084 private Supplier<Path> destination 085 = () -> project().buildDirectory().resolve("distributions"); 086 private Supplier<String> distributionBaseName 087 = () -> project().name() + "-" + project().get(Version); 088 private final StreamCollector<ClasspathElement> resourceStreams 089 = StreamCollector.cached(); 090 private final ApplicationConfigurationData config 091 = new ApplicationConfigurationData(); 092 093 /// Initializes a new application builder. 094 /// 095 /// @param project the project 096 /// 097 public ApplicationBuilder(Project project) { 098 super(Objects.requireNonNull(project)); 099 config.executableName(project().name()); 100 } 101 102 @Override 103 public ApplicationBuilder name(String name) { 104 rename(name); 105 return this; 106 } 107 108 /// Returns the name of the script that starts the application. 109 /// The script for Windows has `.bat` appended to this name. 110 /// 111 /// @return the string 112 /// 113 public String executableName() { 114 return config.executableName(); 115 } 116 117 /// Sets the executable name. 118 /// 119 /// @param name the name 120 /// @return the application builder 121 /// 122 public ApplicationBuilder executableName(String name) { 123 config.executableName(name); 124 return this; 125 } 126 127 /// Returns the destination directory. Defaults to sub directory 128 /// `applications` in the project's build directory 129 /// (see [Project#buildDirectory]). 130 /// 131 /// @return the destination 132 /// 133 public Path destination() { 134 return destination.get(); 135 } 136 137 /// Sets the destination directory. The [Path] is resolved against 138 /// the project's build directory (see [Project#buildDirectory]). 139 /// 140 /// @param destination the new destination 141 /// @return the application builder 142 /// 143 public ApplicationBuilder destination(Path destination) { 144 this.destination 145 = () -> project().buildDirectory().resolve(destination); 146 return this; 147 } 148 149 /// Sets the destination directory. 150 /// 151 /// @param destination the new destination 152 /// @return the jar generator 153 /// 154 public ApplicationBuilder destination(Supplier<Path> destination) { 155 this.destination = destination; 156 return this; 157 } 158 159 /// Returns the base name of the generated TAR or ZIP file. The base 160 /// name is the file name without the extension. Defaults to the 161 /// project's name followed by its version. 162 /// 163 /// @return the string 164 /// 165 public String distributionBaseName() { 166 return distributionBaseName.get(); 167 } 168 169 /// Sets the supplier for obtaining the name of the generated 170 /// ZIP or TAR file's base name in [ResourceProviderSpi#provide]. 171 /// 172 /// @param distributionBaseName the distribution base name 173 /// @return the application builder 174 /// 175 public ApplicationBuilder 176 distributionBaseName(Supplier<String> distributionBaseName) { 177 this.distributionBaseName = distributionBaseName; 178 return this; 179 } 180 181 /// Returns the main class name. 182 /// 183 /// @return the main class name 184 /// 185 public String mainClassName() { 186 return config.mainClassName(); 187 } 188 189 /// Sets the name of the main class (the application entry point). 190 /// 191 /// @param name the new main class name 192 /// @return the jar generator for method chaining 193 /// 194 public ApplicationBuilder mainClassName(String name) { 195 config.mainClassName(Objects.requireNonNull(name)); 196 return this; 197 } 198 199 /// Passes the mutable list of JVM options to the given consumer for 200 /// modification. The start script distinguishes between these options, 201 /// which reflect settings required by the application, and the 202 /// `JAVA_OPTS` that may be used when starting the application to tune 203 /// the JVM for specific environments. 204 /// 205 /// @param modifier the modifier 206 /// @return the list 207 /// 208 public ApplicationBuilder 209 applicationJvmOpts(Consumer<List<String>> modifier) { 210 modifier.accept(config.applicationJvmOpts()); 211 return this; 212 } 213 214 /// Adds the given classpath resources to the application. 215 /// 216 /// @param resources the resources 217 /// @return the application builder 218 /// 219 public ApplicationBuilder 220 add(Stream<? extends ClasspathElement> resources) { 221 resourceStreams.add(resources); 222 return this; 223 } 224 225 @Override 226 protected <T extends Resource> Collection<T> 227 doProvide(ResourceRequest<T> request) { 228 if (!request.accepts(ApplicationZipFileType) 229 && !request.accepts(ApplicationTarFileType) 230 && !request.accepts(CleanlinessType)) { 231 return Collections.emptyList(); 232 } 233 234 // Maybe only delete 235 if (request.accepts(CleanlinessType)) { 236 destination() 237 .resolve(distributionBaseName() + ".zip").toFile().delete(); 238 destination() 239 .resolve(distributionBaseName() + ".tar").toFile().delete(); 240 return Collections.emptyList(); 241 } 242 243 // Make sure mainClass is set 244 if (mainClassName() == null) { 245 throw new ConfigurationException().from(this) 246 .message("Main class must be set for %s", name()); 247 } 248 249 // Prepare the application file 250 var destDir = destination(); 251 if (!destDir.toFile().exists() && !destDir.toFile().mkdirs()) { 252 throw new ConfigurationException().from(this) 253 .message("Cannot create directory " + destDir); 254 } 255 256 // Deduplicate classpath elements by path to avoid duplicate jars 257 var seenPaths = new java.util.HashSet<Path>(); 258 var deduplicatedCpes = resourceStreams.stream() 259 .filter(cpe -> { 260 if (cpe instanceof JarFile jarFile) { 261 return seenPaths.add(jarFile.path()); 262 } 263 return true; 264 }); 265 266 Resources<ClasspathElement> cpes = Resources.of(new ResourceType<>() {}); 267 cpes.addAll(deduplicatedCpes); 268 269 FileResource distFile; 270 if (request.accepts(ApplicationZipFileType)) { 271 distFile = buildZip(cpes); 272 } else { 273 distFile = buildTar(cpes); 274 } 275 @SuppressWarnings("unchecked") 276 var result = (T) distFile; 277 return List.of(result); 278 } 279 280 private FileResource buildZip(Resources<ClasspathElement> cpes) { 281 var zipFile = ZipFile.of(ApplicationZipFileType, 282 destination().resolve(distributionBaseName() + ".zip")); 283 if (cpes.isNewerThan(zipFile)) { 284 logger.atInfo().log("%s building %s", this, zipFile); 285 new ZipDistributionBuilder().build(zipFile, config, cpes); 286 } else { 287 logger.atFine().log("%s found %s to be up to date", this, zipFile); 288 } 289 return zipFile; 290 } 291 292 private FileResource buildTar(Resources<ClasspathElement> cpes) { 293 var tarFile = TarFile.of(ApplicationTarFileType, 294 destination().resolve(distributionBaseName() + ".tar")); 295 if (cpes.isNewerThan(tarFile)) { 296 logger.atInfo().log("%s building %s", this, tarFile); 297 new TarDistributionBuilder().build(tarFile, config, cpes); 298 } else { 299 logger.atFine().log("%s found %s to be up to date", this, tarFile); 300 } 301 return tarFile; 302 } 303}