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.io.BufferedReader; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.InputStreamReader; 026import java.io.OutputStream; 027import java.io.PrintStream; 028import java.nio.charset.Charset; 029import java.nio.charset.StandardCharsets; 030import java.nio.file.Files; 031import java.nio.file.Path; 032import java.nio.file.StandardCopyOption; 033import java.util.Collection; 034import java.util.Collections; 035import java.util.List; 036import java.util.Objects; 037import java.util.function.BiConsumer; 038import java.util.function.Function; 039import java.util.stream.Stream; 040import org.jdrupes.builder.api.BuildException; 041import org.jdrupes.builder.api.Cleanliness; 042import org.jdrupes.builder.api.FileTree; 043import org.jdrupes.builder.api.InputTree; 044import org.jdrupes.builder.api.Project; 045import org.jdrupes.builder.api.Resource; 046import org.jdrupes.builder.api.ResourceFactory; 047import org.jdrupes.builder.api.ResourceRequest; 048import static org.jdrupes.builder.api.ResourceType.CleanlinessType; 049import org.jdrupes.builder.core.AbstractGenerator; 050import org.jdrupes.builder.core.StreamCollector; 051 052/// A provider that generates a [FileTree] from existing file trees. 053/// In general, copying file trees should be avoided. However, in some 054/// situations a resource provider and a consumer cannot be configured 055/// so that the output of the former can be used directly by the latter. 056/// 057/// The provider generates a [FileTree] in the directory specified 058/// with [#into] by copying files from the sources defined with one 059/// of the `source`-methods. The class is not named `Copier` 060/// because the specification of [Source]s supports transformations 061/// beyond simply copying. 062/// 063/// The provider generates the [FileTree] in response to a request that 064/// matches the one set with [#requestForResult]. The content of the 065/// generated file tree is returned using the type specified in the 066/// request. 067/// 068/// A request for [Cleanliness] deletes the directory specified with 069/// [#into]. 070/// 071public class ApplicationBuilder extends AbstractGenerator { 072 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 073 private final StreamCollector<Source> sources = new StreamCollector<>(true); 074 private Path destination; 075 private ResourceRequest<?> requestForResult; 076 077 /// Describes a source that contributes files to the generated 078 /// tree. 079 /// 080 public static final class Source { 081 private final InputTree<?> tree; 082 private Function<Path, Path> rename; 083 private BiConsumer<InputStream, OutputStream> filter; 084 private BiConsumer<BufferedReader, PrintStream> textFilter; 085 private Charset charset; 086 087 private Source(InputTree<?> tree) { 088 this.tree = tree; 089 } 090 091 /// Creates a new source specification. 092 /// 093 /// @param tree the source file tree 094 /// @return the source 095 /// 096 @SuppressWarnings("PMD.ShortMethodName") 097 public static Source of(InputTree<?> tree) { 098 return new Source(tree); 099 } 100 101 /// Specifies a function for renaming source files. The method 102 /// receives the file's path relative to the source tree's root. 103 /// It must return the file's path relative to the destination 104 /// set with [#into]. 105 /// 106 /// @param renamer the function for renaming 107 /// @return the source 108 /// 109 public Source rename(Function<Path, Path> renamer) { 110 this.rename = renamer; 111 return this; 112 } 113 114 /// Specifies a function for copying the content of the source 115 /// file to the destination file. If set, this function 116 /// is invoked for each file instead of simply copying the file 117 /// content. 118 /// 119 /// @param filter the copy function 120 /// @return the source 121 /// 122 public Source filter(BiConsumer<InputStream, OutputStream> filter) { 123 this.filter = filter; 124 return this; 125 } 126 127 /// Specifies a function for copying the content of the source 128 /// text file to the destination text file. If set, this function 129 /// is invoked for each file instead of simply copying the file 130 /// content. 131 /// 132 /// @param filter the copy function 133 /// @param charset the charset 134 /// @return the source 135 /// 136 public Source filter(BiConsumer<BufferedReader, PrintStream> filter, 137 Charset charset) { 138 this.textFilter = filter; 139 this.charset = charset; 140 return this; 141 } 142 143 /// Invoke [String#replaceAll] on each line of the file. 144 /// 145 /// @param regex the regex 146 /// @param replacement the replacement 147 /// @return the source 148 /// 149 public Source replaceAll(String regex, String replacement) { 150 return filter((in, out) -> in.lines().map( 151 l -> l.replaceAll(regex, replacement)) 152 .forEach(out::println), StandardCharsets.UTF_8); 153 } 154 } 155 156 /// Initializes a new file tree builder. 157 /// 158 /// @param project the project 159 /// 160 public ApplicationBuilder(Project project) { 161 super(project); 162 } 163 164 /// Adds the given [Stream] of [Source] specifications. 165 /// 166 /// @param sources the sources 167 /// @return the file tree builder 168 /// 169 public ApplicationBuilder add(Stream<Source> sources) { 170 this.sources.add(sources); 171 return this; 172 } 173 174 /// Adds the given [Source] specification. 175 /// 176 /// @param source the source 177 /// @return the file tree builder 178 /// 179 public ApplicationBuilder add(Source source) { 180 this.sources.add(source); 181 return this; 182 } 183 184 /// Convenience method for adding a [Source] without renaming or 185 /// filter to the sources. If `root` is a relative path, it is resolved 186 /// against the project's directory. 187 /// 188 /// @param root the root 189 /// @param pattern the pattern 190 /// @return the file tree builder 191 /// 192 public ApplicationBuilder source(Path root, String pattern) { 193 sources.add(Stream.of(Source.of(FileTree.of( 194 project(), root, pattern)))); 195 return this; 196 } 197 198 /// Convenience method for adding a [Source] with optional renaming and 199 /// filtering to the sources. If `root` is a relative path, it is 200 /// resolved against the project's directory. 201 /// 202 /// @param root the root 203 /// @param pattern the pattern 204 /// @param renamer the renamer (may be `null`) 205 /// @param filter the filter (may be `null`) 206 /// @return the file tree builder 207 /// 208 public ApplicationBuilder source(Path root, String pattern, 209 Function<Path, Path> renamer, 210 BiConsumer<InputStream, OutputStream> filter) { 211 var source = Source.of(FileTree.of(project(), root, pattern)); 212 if (renamer != null) { 213 source.rename(renamer); 214 } 215 if (filter != null) { 216 source.filter(filter); 217 } 218 sources.add(Stream.of(source)); 219 return this; 220 } 221 222 /// Sets the destination directory for the generated file tree. If the 223 /// destination is relative, it is resolved against the project's 224 /// directory. 225 /// 226 /// @param destination the destination 227 /// @return the file tree builder 228 /// 229 public ApplicationBuilder into(Path destination) { 230 if (!destination.isAbsolute()) { 231 destination = project().directory().resolve(destination); 232 } 233 if (destination.toFile().exists() 234 && !destination.toFile().isDirectory()) { 235 throw new IllegalArgumentException( 236 "Destination path \"" + destination 237 + "\" exists but is not a directory."); 238 } 239 this.destination = destination.normalize(); 240 return this; 241 } 242 243 /// Configures the request that this builder responds to by 244 /// providing the generated file tree. 245 /// 246 /// @param proto a prototype request describing the requests that 247 /// the provider should respond to 248 /// @return the file tree builder 249 /// 250 public ApplicationBuilder provideResources( 251 ResourceRequest<? extends FileTree<?>> proto) { 252 requestForResult = proto; 253 return this; 254 } 255 256 @Override 257 protected <T extends Resource> Collection<T> 258 doProvide(ResourceRequest<T> request) { 259 if (request.accepts(CleanlinessType)) { 260 FileTree.of(project(), destination, "**/*").cleanup(); 261 return Collections.emptyList(); 262 } 263 264 // Check if request matches 265 if (requestForResult == null 266 || !request.accepts(requestForResult.type()) 267 || (!requestForResult.name().isEmpty() 268 && !Objects.equals(requestForResult.name().get(), 269 request.name().orElse(null)))) { 270 return Collections.emptyList(); 271 } 272 273 // Always evaluate for most special type 274 if (!request.equals(requestForResult)) { 275 @SuppressWarnings({ "unchecked" }) 276 var result = (Collection<T>) resources(requestForResult).toList(); 277 return result; 278 } 279 280 if (destination == null) { 281 throw new IllegalStateException("No destination set."); 282 } 283 284 // Retrieve the sources 285 var required = sources.stream().toList(); 286 if (!createInDestination(required)) { 287 logger.atFine().log("Output from %s is up to date", this); 288 } 289 290 var result = ResourceFactory.create(request.type(), project(), 291 destination, new String[] { "**/*" }); 292 return List.of(result); 293 } 294 295 private boolean createInDestination(List<Source> required) { 296 // Handle sources in parallel, but each source in sequentially. 297 return required.parallelStream().map(source -> { 298 var srcTree = source.tree; 299 return srcTree.entries().map(entry -> { 300 try { 301 return createTarget(source, entry); 302 } catch (IOException e) { 303 throw new BuildException().from(this).cause(e); 304 } 305 }).reduce(false, (a, b) -> a || b); 306 }).reduce(false, (a, b) -> a || b); 307 } 308 309 private boolean createTarget(Source source, InputTree.Entry<?> entry) 310 throws IOException { 311 var dest = destination.resolve(entry.path()); 312 var rename = source.rename; 313 if (rename != null) { 314 dest = destination.resolve(rename.apply(entry.path())); 315 if (!dest.normalize().startsWith(destination)) { 316 throw new BuildException().from(this).message( 317 "Rename function returns \"%s\" which is outside the" 318 + " target directory \"%s\"", 319 dest, destination); 320 } 321 } 322 Files.createDirectories(dest.getParent()); 323 if (dest.toFile().exists() && dest.toFile() 324 .lastModified() >= entry.resource().asOf().get().toEpochMilli()) { 325 return false; 326 } 327 if (source.filter != null) { 328 try (var srcStream = entry.resource().inputStream(); 329 var destStream = Files.newOutputStream(dest)) { 330 source.filter.accept(srcStream, destStream); 331 } 332 return true; 333 } 334 if (source.textFilter != null) { 335 try (var reader = new BufferedReader(new InputStreamReader( 336 entry.resource().inputStream(), source.charset)); 337 var out 338 = new PrintStream(dest.toFile(), source.charset)) { 339 source.textFilter.accept(reader, out); 340 } 341 return true; 342 } 343 Files.copy(entry.resource().inputStream(), dest, 344 StandardCopyOption.REPLACE_EXISTING); 345 return true; 346 } 347 348}