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