001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 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 io.github.azagniotov.matcher.AntPathMatcher; 023import java.io.IOException; 024import java.nio.file.AccessDeniedException; 025import java.nio.file.FileVisitResult; 026import java.nio.file.Files; 027import java.nio.file.NoSuchFileException; 028import java.nio.file.Path; 029import java.nio.file.SimpleFileVisitor; 030import java.nio.file.attribute.BasicFileAttributes; 031import java.time.Instant; 032import java.util.ArrayList; 033import java.util.Arrays; 034import java.util.List; 035import java.util.Objects; 036import java.util.Optional; 037import java.util.stream.Stream; 038import org.jdrupes.builder.api.BuildException; 039import org.jdrupes.builder.api.FileResource; 040import org.jdrupes.builder.api.FileTree; 041import org.jdrupes.builder.api.Project; 042import org.jdrupes.builder.api.ResourceFactory; 043import org.jdrupes.builder.api.ResourceType; 044 045/// The default implementation of a [FileTree]. 046/// 047/// @param <T> the type of the [FileResource]s in the tree. 048/// 049public class DefaultFileTree<T extends FileResource> extends DefaultResources<T> 050 implements FileTree<T> { 051 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 052 @SuppressWarnings("PMD.FieldNamingConventions") 053 private static final AntPathMatcher pathMatcher 054 = new AntPathMatcher.Builder().build(); 055 private Instant latestChange; 056 private final Project project; 057 private final Path root; 058 private final String[] patterns; 059 private final List<String> excludes = new ArrayList<>(); 060 private boolean withDirs; 061 private boolean filled; 062 063 /// Returns a new file tree. The file tree includes all files 064 /// matching `pattern` in the tree starting at `root`. `root` 065 /// may be specified as absolute path or as path relative to the 066 /// `project`'s directory (see [Project#directory]). 067 /// 068 /// if `project` is `null`, and `root` is a relative path, 069 /// `root` is resolved against the current working directory. 070 /// 071 /// @param type the resource type 072 /// @param project the project 073 /// @param root the root 074 /// @param patterns the include patterns 075 /// 076 @SuppressWarnings({ "PMD.ArrayIsStoredDirectly", "PMD.UseVarargs" }) 077 protected DefaultFileTree(ResourceType<?> type, Project project, Path root, 078 String[] patterns) { 079 super(type); 080 this.project = project; 081 this.root = root; 082 if (patterns.length == 0) { 083 this.patterns = new String[] { "**/*" }; 084 } else { 085 this.patterns = patterns; 086 } 087 } 088 089 @Override 090 public FileTree<T> withDirectories() { 091 withDirs = true; 092 return this; 093 } 094 095 @Override 096 public FileTree<T> exclude(String pattern) { 097 excludes.add(pattern); 098 return this; 099 } 100 101 @Override 102 public Path root(boolean relativize) { 103 if (project == null) { 104 return root.toAbsolutePath(); 105 } 106 Path result = project.directory().resolve(root).normalize(); 107 if (relativize) { 108 return project.directory().relativize(result); 109 } 110 return result; 111 } 112 113 @Override 114 public Path root() { 115 return root(false); 116 } 117 118 private void fill() { 119 if (filled) { 120 return; 121 } 122 try { 123 find(root(), patterns); 124 } catch (IOException e) { 125 logger.atSevere().withCause(e).log("Problem scanning files"); 126 throw new BuildException().from(project).cause(e); 127 } 128 filled = true; 129 } 130 131 @Override 132 public Optional<Instant> asOf() { 133 fill(); 134 return Optional.ofNullable(latestChange); 135 } 136 137 @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.UseVarargs" }) 138 private void find(Path root, String[] patterns) throws IOException { 139 if (!root.toFile().exists()) { 140 return; 141 } 142 Files.walkFileTree(root, new SimpleFileVisitor<>() { 143 144 @Override 145 public FileVisitResult visitFile(Path path, 146 BasicFileAttributes attrs) throws IOException { 147 return testAndAdd(path); 148 } 149 150 private FileVisitResult testAndAdd(Path path) { 151 Path pathInTree = root.relativize(path); 152 if (excludes.stream().anyMatch(ex -> pathMatcher 153 .isMatch(ex, pathInTree.toString()))) { 154 if (path.toFile().isDirectory()) { 155 return FileVisitResult.SKIP_SUBTREE; 156 } 157 return FileVisitResult.CONTINUE; 158 } 159 if (Arrays.stream(patterns).anyMatch(pattern -> pathMatcher 160 .isMatch(pattern, pathInTree.toString()))) { 161 @SuppressWarnings("unchecked") 162 T resource = (T) ResourceFactory 163 .create(type().containedType(), path); 164 DefaultFileTree.this.add(resource); 165 if (resource.asOf().isPresent() && (latestChange == null 166 || resource.asOf().get().isAfter(latestChange))) { 167 latestChange = resource.asOf().get(); 168 } 169 } 170 return FileVisitResult.CONTINUE; 171 } 172 173 @Override 174 public FileVisitResult preVisitDirectory(Path dir, 175 BasicFileAttributes attrs) throws IOException { 176 if (withDirs) { 177 return testAndAdd(dir); 178 } 179 return FileVisitResult.CONTINUE; 180 } 181 182 @Override 183 public FileVisitResult postVisitDirectory(Path dir, IOException exc) 184 throws IOException { 185 if (!withDirs) { 186 return FileVisitResult.CONTINUE; 187 } 188 189 // Directories (and their modification date) included 190 var dirMod = Instant.ofEpochMilli(dir.toFile().lastModified()); 191 if (latestChange == null || dirMod.isAfter(latestChange)) { 192 latestChange = dirMod; 193 } 194 return FileVisitResult.CONTINUE; 195 } 196 197 @Override 198 public FileVisitResult visitFileFailed(Path file, IOException exc) 199 throws IOException { 200 if (exc instanceof AccessDeniedException) { 201 return FileVisitResult.SKIP_SUBTREE; 202 } 203 return FileVisitResult.CONTINUE; 204 } 205 }); 206 } 207 208 @Override 209 public Stream<T> stream() { 210 return LazyCollectionStream.of(() -> { 211 fill(); 212 return get(); 213 }); 214 } 215 216 @Override 217 public FileTree<T> clear() { 218 super.clear(); 219 filled = false; 220 return this; 221 } 222 223 @Override 224 public void cleanup() { 225 try { 226 deleteFiles(root()); 227 } catch (IOException e) { 228 logger.atSevere().withCause(e).log("Problem scanning files"); 229 throw new BuildException().from(project).cause(e); 230 } 231 filled = false; 232 } 233 234 @SuppressWarnings("PMD.CognitiveComplexity") 235 private void deleteFiles(Path root) throws IOException { 236 if (!root.toFile().exists()) { 237 return; 238 } 239 Files.walkFileTree(root, new SimpleFileVisitor<>() { 240 241 @Override 242 public FileVisitResult visitFile(Path path, 243 BasicFileAttributes attrs) throws IOException { 244 return testAndDelete(path); 245 } 246 247 private FileVisitResult testAndDelete(Path path) 248 throws IOException { 249 Path pathInTree = root.relativize(path); 250 if (excludes.stream().anyMatch(ex -> pathMatcher 251 .isMatch(ex, pathInTree.toString()))) { 252 if (path.toFile().isDirectory()) { 253 return FileVisitResult.SKIP_SUBTREE; 254 } 255 return FileVisitResult.CONTINUE; 256 } 257 if (Arrays.stream(patterns).anyMatch(pattern -> pathMatcher 258 .isMatch(pattern, pathInTree.toString()))) { 259 try { 260 Files.delete(path); 261 } catch (NoSuchFileException e) { // NOPMD 262 // We can have concurrent cleanups 263 } 264 } 265 return FileVisitResult.CONTINUE; 266 } 267 268 @Override 269 public FileVisitResult postVisitDirectory(Path dir, 270 IOException exc) throws IOException { 271 if (exc != null) { 272 return FileVisitResult.CONTINUE; 273 } 274 if (dir.toFile().exists()) { 275 try { 276 if (Files.list(dir).findFirst().isEmpty()) { 277 Files.delete(dir); 278 } 279 } catch (NoSuchFileException e) { // NOPMD 280 // We can have concurrent cleanups 281 } 282 } 283 return FileVisitResult.CONTINUE; 284 } 285 286 @Override 287 public FileVisitResult visitFileFailed(Path file, 288 IOException exc) throws IOException { 289 if (exc instanceof AccessDeniedException) { 290 return FileVisitResult.SKIP_SUBTREE; 291 } 292 return FileVisitResult.CONTINUE; 293 } 294 }); 295 } 296 297 @Override 298 public Stream<Path> paths() { 299 return stream().map(fr -> root().relativize(fr.path())); 300 } 301 302 @SuppressWarnings("unchecked") 303 @Override 304 public Stream<Entry<T>> entries() { 305 return paths().map(path -> new Entry<T>(path, 306 ResourceFactory.create((ResourceType<T>) type().containedType(), 307 root().resolve(path)))); 308 } 309 310 @Override 311 public int hashCode() { 312 final int prime = 31; 313 int result = super.hashCode(); 314 result 315 = prime * result + Objects.hash(excludes, patterns, root, withDirs); 316 return result; 317 } 318 319 @Override 320 public boolean equals(Object obj) { 321 if (this == obj) { 322 return true; 323 } 324 if (!super.equals(obj)) { 325 return false; 326 } 327 return (obj instanceof DefaultFileTree other) 328 && Objects.equals(excludes, other.excludes) 329 && Objects.equals(patterns, other.patterns) 330 && Objects.equals(root, other.root) && withDirs == other.withDirs; 331 } 332 333 @Override 334 public String toString() { 335 var wasFilled = filled; 336 fill(); 337 String str = type().toString() + " (" + asOfLocalized() 338 + ") from " + Path.of("").toAbsolutePath().relativize(root()) 339 + " with " + stream().count() + " elements"; 340 if (!wasFilled) { 341 clear(); 342 } 343 filled = wasFilled; 344 return str; 345 } 346}