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