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 to a request that 062/// matches the one set with [#requestForResult]. The content of the 063/// generated file tree is returned using the type specified in the 064/// 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 copying the content of the source 113 /// file to the destination 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 copying the content of the source 126 /// text file to the destination text 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 /// Invoke [String#replaceAll] on each line of the file. 142 /// 143 /// @param regex the regex 144 /// @param replacement the replacement 145 /// @return the source 146 /// 147 public Source replaceAll(String regex, String replacement) { 148 return filter((in, out) -> in.lines().map( 149 l -> l.replaceAll(regex, replacement)) 150 .forEach(out::println), StandardCharsets.UTF_8); 151 } 152 } 153 154 /// Initializes a new file tree builder. 155 /// 156 /// @param project the project 157 /// 158 public FileTreeBuilder(Project project) { 159 super(project); 160 } 161 162 /// Adds the given [Stream] of [Source] specifications. 163 /// 164 /// @param sources the sources 165 /// @return the file tree builder 166 /// 167 public FileTreeBuilder add(Stream<Source> sources) { 168 this.sources.add(sources); 169 return this; 170 } 171 172 /// Adds the given [Source] specification. 173 /// 174 /// @param source the source 175 /// @return the file tree builder 176 /// 177 public FileTreeBuilder add(Source source) { 178 this.sources.add(source); 179 return this; 180 } 181 182 /// Convenience method for adding a [Source] without renaming or 183 /// filter to the sources. If `root` is a relative path, it is resolved 184 /// against the project's directory. 185 /// 186 /// @param root the root 187 /// @param pattern the pattern 188 /// @return the file tree builder 189 /// 190 public FileTreeBuilder source(Path root, String pattern) { 191 sources.add(Stream.of(Source.of(FileTree.of( 192 project(), root, pattern)))); 193 return this; 194 } 195 196 /// Convenience method for adding a [Source] with optional renaming and 197 /// filtering to the sources. If `root` is a relative path, it is 198 /// resolved against the project's directory. 199 /// 200 /// @param root the root 201 /// @param pattern the pattern 202 /// @param renamer the renamer (may be `null`) 203 /// @param filter the filter (may be `null`) 204 /// @return the file tree builder 205 /// 206 public FileTreeBuilder source(Path root, String pattern, 207 Function<Path, Path> renamer, 208 BiConsumer<InputStream, OutputStream> filter) { 209 var source = Source.of(FileTree.of(project(), root, pattern)); 210 if (renamer != null) { 211 source.rename(renamer); 212 } 213 if (filter != null) { 214 source.filter(filter); 215 } 216 sources.add(Stream.of(source)); 217 return this; 218 } 219 220 /// Sets the destination directory for the generated file tree. If the 221 /// destination is relative, it is resolved against the project's 222 /// directory. 223 /// 224 /// @param destination the destination 225 /// @return the file tree builder 226 /// 227 public FileTreeBuilder into(Path destination) { 228 if (!destination.isAbsolute()) { 229 destination = project().directory().resolve(destination); 230 } 231 if (destination.toFile().exists() 232 && !destination.toFile().isDirectory()) { 233 throw new IllegalArgumentException( 234 "Destination path \"" + destination 235 + "\" exists but is not a directory."); 236 } 237 this.destination = destination.normalize(); 238 return this; 239 } 240 241 /// Configures the request that this builder responds to by 242 /// providing the generated file tree. 243 /// 244 /// @param proto a prototype request describing the requests that 245 /// the provider should respond to 246 /// @return the file tree builder 247 /// 248 public FileTreeBuilder provideResources( 249 ResourceRequest<? extends FileTree<?>> proto) { 250 requestForResult = proto; 251 return this; 252 } 253 254 @Override 255 protected <T extends Resource> Collection<T> 256 doProvide(ResourceRequest<T> request) { 257 if (request.accepts(CleanlinessType)) { 258 FileTree.of(project(), destination, "**/*").cleanup(); 259 return Collections.emptyList(); 260 } 261 262 // Check if request matches 263 if (requestForResult == null 264 || !request.accepts(requestForResult.type()) 265 || (!requestForResult.name().isEmpty() 266 && !Objects.equals(requestForResult.name().get(), 267 request.name().orElse(null)))) { 268 return Collections.emptyList(); 269 } 270 271 // Always evaluate for most special type 272 if (!request.equals(requestForResult)) { 273 @SuppressWarnings({ "unchecked" }) 274 var result = (Collection<T>) resources(requestForResult).toList(); 275 return result; 276 } 277 278 if (destination == null) { 279 throw new IllegalStateException("No destination set."); 280 } 281 282 // Retrieve the sources 283 var required = sources.stream().toList(); 284 if (!createInDestination(required)) { 285 logger.atFine().log("Output from %s is up to date", this); 286 } 287 288 var result = ResourceFactory.create(request.type(), project(), 289 destination, new String[] { "**/*" }); 290 return List.of(result); 291 } 292 293 private boolean createInDestination(List<Source> required) { 294 // Handle sources in parallel, but each source in sequentially. 295 return required.parallelStream().map(source -> { 296 var srcTree = source.tree; 297 return srcTree.entries().map(entry -> { 298 try { 299 return createTarget(source, entry); 300 } catch (IOException e) { 301 throw new BuildException().from(this).cause(e); 302 } 303 }).reduce(false, (a, b) -> a || b); 304 }).reduce(false, (a, b) -> a || b); 305 } 306 307 private boolean createTarget(Source source, InputTree.Entry<?> entry) 308 throws IOException { 309 var dest = destination.resolve(entry.path()); 310 var rename = source.rename; 311 if (rename != null) { 312 dest = destination.resolve(rename.apply(entry.path())); 313 if (!dest.normalize().startsWith(destination)) { 314 throw new BuildException().from(this).message( 315 "Rename function returns \"%s\" which is outside the" 316 + " target directory \"%s\"", 317 dest, destination); 318 } 319 } 320 Files.createDirectories(dest.getParent()); 321 if (dest.toFile().exists() && dest.toFile() 322 .lastModified() >= entry.resource().asOf().get().toEpochMilli()) { 323 return false; 324 } 325 if (source.filter != null) { 326 try (var srcStream = entry.resource().inputStream(); 327 var destStream = Files.newOutputStream(dest)) { 328 source.filter.accept(srcStream, destStream); 329 } 330 return true; 331 } 332 if (source.textFilter != null) { 333 try (var reader = new BufferedReader(new InputStreamReader( 334 entry.resource().inputStream(), source.charset)); 335 var out 336 = new PrintStream(dest.toFile(), source.charset)) { 337 source.textFilter.accept(reader, out); 338 } 339 return true; 340 } 341 Files.copy(entry.resource().inputStream(), dest, 342 StandardCopyOption.REPLACE_EXISTING); 343 return true; 344 } 345 346}