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 java.io.IOException; 023import java.nio.file.AccessDeniedException; 024import java.nio.file.FileSystems; 025import java.nio.file.FileVisitResult; 026import java.nio.file.Files; 027import java.nio.file.Path; 028import java.nio.file.PathMatcher; 029import java.nio.file.SimpleFileVisitor; 030import java.nio.file.attribute.BasicFileAttributes; 031import java.time.Instant; 032import java.util.ArrayList; 033import java.util.Iterator; 034import java.util.List; 035import java.util.Objects; 036import java.util.Spliterators; 037import java.util.function.Consumer; 038import java.util.stream.Stream; 039import java.util.stream.StreamSupport; 040import org.jdrupes.builder.api.BuildException; 041import org.jdrupes.builder.api.FileResource; 042import org.jdrupes.builder.api.FileTree; 043import org.jdrupes.builder.api.Project; 044import org.jdrupes.builder.api.ResourceFactory; 045import org.jdrupes.builder.api.ResourceType; 046 047/// The default implementation of a [FileTree]. 048/// 049/// @param <T> the type of the [FileResource]s in the tree. 050/// 051public class DefaultFileTree<T extends FileResource> extends DefaultResources<T> 052 implements FileTree<T> { 053 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 054 private Instant latestChange = Instant.MIN; 055 private final Project project; 056 private final Path root; 057 private final String pattern; 058 private final List<String> excludes = new ArrayList<>(); 059 private boolean withDirs; 060 private boolean filled; 061 062 /// Returns a new file tree. The file tree includes all files 063 /// matching `pattern` in the tree starting at `root`. `root` 064 /// may be specified as absolute path or as path relative to the 065 /// `project`'s directory (see [Project#directory]). 066 /// 067 /// if `project` is `null`, and `root` is a relative path, 068 /// `root` is resolved against the current working directory. 069 /// `pattern` 070 /// 071 /// @param type the resource type 072 /// @param project the project 073 /// @param root the root 074 /// @param pattern the pattern 075 /// 076 protected DefaultFileTree(ResourceType<?> type, Project project, Path root, 077 String pattern) { 078 super(type); 079 this.project = project; 080 this.root = root; 081 this.pattern = pattern; 082 } 083 084 @Override 085 public FileTree<T> withDirectories() { 086 withDirs = true; 087 return this; 088 } 089 090 @Override 091 public FileTree<T> exclude(String pattern) { 092 excludes.add(pattern); 093 return this; 094 } 095 096 @Override 097 public Path root(boolean relativize) { 098 if (project == null) { 099 return root.toAbsolutePath(); 100 } 101 Path result = project.directory().resolve(root).normalize(); 102 if (relativize) { 103 return project.directory().relativize(result); 104 } 105 return result; 106 } 107 108 @Override 109 public Path root() { 110 return root(false); 111 } 112 113 private void fill() { 114 if (filled) { 115 return; 116 } 117 try { 118 find(root(), pattern); 119 } catch (IOException e) { 120 logger.atSevere().withCause(e).log("Problem scanning files"); 121 throw new BuildException().from(project).cause(e); 122 } 123 filled = true; 124 } 125 126 @Override 127 public Instant asOf() { 128 fill(); 129 return latestChange; 130 } 131 132 @SuppressWarnings("PMD.CognitiveComplexity") 133 private void find(Path root, String pattern) throws IOException { 134 final PathMatcher pathMatcher = FileSystems.getDefault() 135 .getPathMatcher("glob:" + pattern); 136 final var excludeMatchers = excludes.parallelStream() 137 .map(e -> FileSystems.getDefault() 138 .getPathMatcher("glob:" + e)) 139 .toList(); 140 Files.walkFileTree(root, new SimpleFileVisitor<>() { 141 142 @Override 143 public FileVisitResult visitFile(Path path, 144 BasicFileAttributes attrs) throws IOException { 145 return testAndAdd(path); 146 } 147 148 private FileVisitResult testAndAdd(Path path) { 149 if (excludeMatchers.parallelStream() 150 .filter(em -> em.matches(root.relativize(path))) 151 .findAny().isPresent()) { 152 if (path.toFile().isDirectory()) { 153 return FileVisitResult.SKIP_SUBTREE; 154 } 155 return FileVisitResult.CONTINUE; 156 } 157 if (pathMatcher.matches(path)) { 158 @SuppressWarnings("unchecked") 159 T resource = (T) ResourceFactory 160 .create(type().containedType(), path); 161 DefaultFileTree.this.add(resource); 162 if (resource.asOf().isAfter(latestChange)) { 163 latestChange = resource.asOf(); 164 } 165 return FileVisitResult.CONTINUE; 166 } 167 return FileVisitResult.CONTINUE; 168 } 169 170 @Override 171 public FileVisitResult preVisitDirectory(Path dir, 172 BasicFileAttributes attrs) throws IOException { 173 if (withDirs) { 174 return testAndAdd(dir); 175 } 176 return FileVisitResult.CONTINUE; 177 } 178 179 @Override 180 public FileVisitResult postVisitDirectory(Path dir, IOException exc) 181 throws IOException { 182 var dirMod = Instant.ofEpochMilli(dir.toFile().lastModified()); 183 if (dirMod.isAfter(latestChange)) { 184 latestChange = dirMod; 185 } 186 return FileVisitResult.CONTINUE; 187 } 188 189 @Override 190 public FileVisitResult visitFileFailed(Path file, IOException exc) 191 throws IOException { 192 if (exc instanceof AccessDeniedException) { 193 return FileVisitResult.SKIP_SUBTREE; 194 } 195 return FileVisitResult.CONTINUE; 196 } 197 }); 198 } 199 200 @Override 201 public Stream<T> stream() { 202 return StreamSupport 203 .stream(new Spliterators.AbstractSpliterator<>(Long.MAX_VALUE, 0) { 204 205 private Iterator<T> theIterator; 206 207 private Iterator<T> iterator() { 208 if (theIterator == null) { 209 fill(); 210 theIterator = DefaultFileTree.super.stream().iterator(); 211 } 212 return theIterator; 213 } 214 215 @Override 216 public void forEachRemaining(Consumer<? super T> action) { 217 iterator().forEachRemaining(action); 218 } 219 220 @Override 221 public boolean tryAdvance(Consumer<? super T> action) { 222 if (!iterator().hasNext()) { 223 return false; 224 } 225 action.accept(iterator().next()); 226 return true; 227 } 228 }, false); 229 } 230 231 @Override 232 public FileTree<T> clear() { 233 super.clear(); 234 filled = false; 235 return this; 236 } 237 238 @Override 239 public FileTree<T> delete() { 240 final PathMatcher pathMatcher = FileSystems.getDefault() 241 .getPathMatcher("glob:" + pattern); 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 (pathMatcher.matches(path)) { 250 Files.delete(path); 251 } 252 return FileVisitResult.CONTINUE; 253 } 254 255 @Override 256 public FileVisitResult postVisitDirectory(Path dir, 257 IOException exc) throws IOException { 258 if (exc != null) { 259 return FileVisitResult.CONTINUE; 260 } 261 if (!dir.equals(root) 262 && Files.list(dir).findFirst().isEmpty()) { 263 Files.delete(dir); 264 } 265 return FileVisitResult.CONTINUE; 266 } 267 268 @Override 269 public FileVisitResult visitFileFailed(Path file, 270 IOException exc) throws IOException { 271 if (exc instanceof AccessDeniedException) { 272 return FileVisitResult.SKIP_SUBTREE; 273 } 274 return FileVisitResult.CONTINUE; 275 } 276 }); 277 } catch (IOException e) { 278 logger.atSevere().withCause(e).log("Problem scanning files"); 279 throw new BuildException().from(project).cause(e); 280 } 281 filled = false; 282 return this; 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, pattern, 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(pattern, other.pattern) 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}