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.Path; 028import java.nio.file.SimpleFileVisitor; 029import java.nio.file.attribute.BasicFileAttributes; 030import java.time.Instant; 031import java.util.ArrayList; 032import java.util.Arrays; 033import java.util.Iterator; 034import java.util.List; 035import java.util.Objects; 036import java.util.Optional; 037import java.util.Spliterators; 038import java.util.function.Consumer; 039import java.util.stream.Stream; 040import java.util.stream.StreamSupport; 041import org.jdrupes.builder.api.BuildException; 042import org.jdrupes.builder.api.FileResource; 043import org.jdrupes.builder.api.FileTree; 044import org.jdrupes.builder.api.Project; 045import org.jdrupes.builder.api.ResourceFactory; 046import org.jdrupes.builder.api.ResourceType; 047 048/// The default implementation of a [FileTree]. 049/// 050/// @param <T> the type of the [FileResource]s in the tree. 051/// 052public class DefaultFileTree<T extends FileResource> extends DefaultResources<T> 053 implements FileTree<T> { 054 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 055 @SuppressWarnings("PMD.FieldNamingConventions") 056 private static final AntPathMatcher pathMatcher 057 = new AntPathMatcher.Builder().build(); 058 private Instant latestChange; 059 private final Project project; 060 private final Path root; 061 private final String[] patterns; 062 private final List<String> excludes = new ArrayList<>(); 063 private boolean withDirs; 064 private boolean filled; 065 066 /// Returns a new file tree. The file tree includes all files 067 /// matching `pattern` in the tree starting at `root`. `root` 068 /// may be specified as absolute path or as path relative to the 069 /// `project`'s directory (see [Project#directory]). 070 /// 071 /// if `project` is `null`, and `root` is a relative path, 072 /// `root` is resolved against the current working directory. 073 /// `pattern` 074 /// 075 /// @param type the resource type 076 /// @param project the project 077 /// @param root the root 078 /// @param patterns the patterns 079 /// 080 @SuppressWarnings({ "PMD.ArrayIsStoredDirectly", "PMD.UseVarargs" }) 081 protected DefaultFileTree(ResourceType<?> type, Project project, Path root, 082 String[] patterns) { 083 super(type); 084 this.project = project; 085 this.root = root; 086 this.patterns = patterns; 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 Files.walkFileTree(root, new SimpleFileVisitor<>() { 140 141 @Override 142 public FileVisitResult visitFile(Path path, 143 BasicFileAttributes attrs) throws IOException { 144 return testAndAdd(path); 145 } 146 147 private FileVisitResult testAndAdd(Path path) { 148 Path pathInTree = root.relativize(path); 149 if (excludes.stream().filter(ex -> pathMatcher 150 .isMatch(ex, pathInTree.toString())) 151 .findAny().isPresent()) { 152 if (path.toFile().isDirectory()) { 153 return FileVisitResult.SKIP_SUBTREE; 154 } 155 return FileVisitResult.CONTINUE; 156 } 157 if (Arrays.stream(patterns).anyMatch(pattern -> pathMatcher 158 .isMatch(pattern, pathInTree.toString()))) { 159 @SuppressWarnings("unchecked") 160 T resource = (T) ResourceFactory 161 .create(type().containedType(), path); 162 DefaultFileTree.this.add(resource); 163 if (resource.asOf().isPresent() && (latestChange == null 164 || resource.asOf().get().isAfter(latestChange))) { 165 latestChange = resource.asOf().get(); 166 } 167 return FileVisitResult.CONTINUE; 168 } 169 return FileVisitResult.CONTINUE; 170 } 171 172 @Override 173 public FileVisitResult preVisitDirectory(Path dir, 174 BasicFileAttributes attrs) throws IOException { 175 if (withDirs) { 176 return testAndAdd(dir); 177 } 178 return FileVisitResult.CONTINUE; 179 } 180 181 @Override 182 public FileVisitResult postVisitDirectory(Path dir, IOException exc) 183 throws IOException { 184 var dirMod = Instant.ofEpochMilli(dir.toFile().lastModified()); 185 if (latestChange == null || dirMod.isAfter(latestChange)) { 186 latestChange = dirMod; 187 } 188 return FileVisitResult.CONTINUE; 189 } 190 191 @Override 192 public FileVisitResult visitFileFailed(Path file, IOException exc) 193 throws IOException { 194 if (exc instanceof AccessDeniedException) { 195 return FileVisitResult.SKIP_SUBTREE; 196 } 197 return FileVisitResult.CONTINUE; 198 } 199 }); 200 } 201 202 @Override 203 public Stream<T> stream() { 204 return StreamSupport 205 .stream(new Spliterators.AbstractSpliterator<>(Long.MAX_VALUE, 0) { 206 207 private Iterator<T> theIterator; 208 209 private Iterator<T> iterator() { 210 if (theIterator == null) { 211 fill(); 212 theIterator = DefaultFileTree.super.stream().iterator(); 213 } 214 return theIterator; 215 } 216 217 @Override 218 public void forEachRemaining(Consumer<? super T> action) { 219 iterator().forEachRemaining(action); 220 } 221 222 @Override 223 public boolean tryAdvance(Consumer<? super T> action) { 224 if (!iterator().hasNext()) { 225 return false; 226 } 227 action.accept(iterator().next()); 228 return true; 229 } 230 }, false); 231 } 232 233 @Override 234 public FileTree<T> clear() { 235 super.clear(); 236 filled = false; 237 return this; 238 } 239 240 @Override 241 public void cleanup() { 242 try { 243 var root = root(); 244 Files.walkFileTree(root, new SimpleFileVisitor<>() { 245 246 @Override 247 public FileVisitResult visitFile(Path path, 248 BasicFileAttributes attrs) throws IOException { 249 if (Arrays.stream(patterns).anyMatch(pattern -> pathMatcher 250 .isMatch(pattern, path.toString()))) { 251 Files.delete(path); 252 } 253 return FileVisitResult.CONTINUE; 254 } 255 256 @Override 257 public FileVisitResult postVisitDirectory(Path dir, 258 IOException exc) throws IOException { 259 if (exc != null) { 260 return FileVisitResult.CONTINUE; 261 } 262 if (dir.toFile().exists() 263 && Files.list(dir).findFirst().isEmpty()) { 264 Files.delete(dir); 265 } 266 return FileVisitResult.CONTINUE; 267 } 268 269 @Override 270 public FileVisitResult visitFileFailed(Path file, 271 IOException exc) throws IOException { 272 if (exc instanceof AccessDeniedException) { 273 return FileVisitResult.SKIP_SUBTREE; 274 } 275 return FileVisitResult.CONTINUE; 276 } 277 }); 278 } catch (IOException e) { 279 logger.atSevere().withCause(e).log("Problem scanning files"); 280 throw new BuildException().from(project).cause(e); 281 } 282 filled = false; 283 } 284 285 @Override 286 public Stream<Path> entries() { 287 return stream().map(fr -> root().relativize(fr.path())); 288 } 289 290 @Override 291 public int hashCode() { 292 final int prime = 31; 293 int result = super.hashCode(); 294 result 295 = prime * result + Objects.hash(excludes, patterns, root, withDirs); 296 return result; 297 } 298 299 @Override 300 public boolean equals(Object obj) { 301 if (this == obj) { 302 return true; 303 } 304 if (!super.equals(obj)) { 305 return false; 306 } 307 return (obj instanceof DefaultFileTree other) 308 && Objects.equals(excludes, other.excludes) 309 && Objects.equals(patterns, other.patterns) 310 && Objects.equals(root, other.root) && withDirs == other.withDirs; 311 } 312 313 @Override 314 public String toString() { 315 var wasFilled = filled; 316 fill(); 317 String str = type().toString() + " (" + asOfLocalized() 318 + ") from " + Path.of("").toAbsolutePath().relativize(root()) 319 + " with " + stream().count() + " elements"; 320 if (!wasFilled) { 321 clear(); 322 } 323 filled = wasFilled; 324 return str; 325 } 326}