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 java.io.IOException; 022import java.lang.reflect.Proxy; 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.Proxyable; 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 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 /// Creates the a new [FileTree]. 085 /// 086 /// @param <T> the tree's type 087 /// @param type the type 088 /// @param project the project 089 /// @param root the root 090 /// @param pattern the pattern 091 /// @return the file tree 092 /// 093 @SuppressWarnings("unchecked") 094 public static <T extends FileTree<?>> 095 T createFileTree(ResourceType<T> type, Project project, Path root, 096 String pattern) { 097 return (T) Proxy.newProxyInstance(type.rawType().getClassLoader(), 098 new Class<?>[] { type.rawType(), Proxyable.class }, 099 new ForwardingHandler( 100 new DefaultFileTree<>(type, project, root, pattern))); 101 } 102 103 @Override 104 public FileTree<T> withDirectories() { 105 withDirs = true; 106 return this; 107 } 108 109 @Override 110 public FileTree<T> exclude(String pattern) { 111 excludes.add(pattern); 112 return this; 113 } 114 115 @Override 116 public Path root(boolean relativize) { 117 if (project == null) { 118 return root.toAbsolutePath(); 119 } 120 Path result = project.directory().resolve(root).normalize(); 121 if (relativize) { 122 return project.directory().relativize(result); 123 } 124 return result; 125 } 126 127 @Override 128 public Path root() { 129 return root(false); 130 } 131 132 private void fill() { 133 if (filled) { 134 return; 135 } 136 try { 137 find(root(), pattern); 138 } catch (IOException e) { 139 log.log(java.util.logging.Level.SEVERE, e, 140 () -> "Problem scanning files: " + e.getMessage()); 141 throw new BuildException(e); 142 } 143 filled = true; 144 } 145 146 @Override 147 public Instant asOf() { 148 fill(); 149 return latestChange; 150 } 151 152 @SuppressWarnings("PMD.CognitiveComplexity") 153 private void find(Path root, String pattern) throws IOException { 154 final PathMatcher pathMatcher = FileSystems.getDefault() 155 .getPathMatcher("glob:" + pattern); 156 final var excludeMatchers = excludes.parallelStream() 157 .map(e -> FileSystems.getDefault() 158 .getPathMatcher("glob:" + e)) 159 .toList(); 160 Files.walkFileTree(root, new SimpleFileVisitor<>() { 161 162 @Override 163 public FileVisitResult visitFile(Path path, 164 BasicFileAttributes attrs) throws IOException { 165 return testAndAdd(path); 166 } 167 168 private FileVisitResult testAndAdd(Path path) { 169 if (excludeMatchers.parallelStream() 170 .filter(em -> em.matches(root.relativize(path))) 171 .findAny().isPresent()) { 172 if (path.toFile().isDirectory()) { 173 return FileVisitResult.SKIP_SUBTREE; 174 } 175 return FileVisitResult.CONTINUE; 176 } 177 if (pathMatcher.matches(path)) { 178 @SuppressWarnings("unchecked") 179 T resource = (T) ResourceFactory 180 .create(type().containedType(), path); 181 DefaultFileTree.this.add(resource); 182 if (resource.asOf().isAfter(latestChange)) { 183 latestChange = resource.asOf(); 184 } 185 return FileVisitResult.CONTINUE; 186 } 187 return FileVisitResult.CONTINUE; 188 } 189 190 @Override 191 public FileVisitResult preVisitDirectory(Path dir, 192 BasicFileAttributes attrs) throws IOException { 193 if (withDirs) { 194 return testAndAdd(dir); 195 } 196 return FileVisitResult.CONTINUE; 197 } 198 199 @Override 200 public FileVisitResult postVisitDirectory(Path dir, IOException exc) 201 throws IOException { 202 var dirMod = Instant.ofEpochMilli(dir.toFile().lastModified()); 203 if (dirMod.isAfter(latestChange)) { 204 latestChange = dirMod; 205 } 206 return FileVisitResult.CONTINUE; 207 } 208 209 @Override 210 public FileVisitResult visitFileFailed(Path file, IOException exc) 211 throws IOException { 212 if (exc instanceof AccessDeniedException) { 213 return FileVisitResult.SKIP_SUBTREE; 214 } 215 return FileVisitResult.CONTINUE; 216 } 217 }); 218 } 219 220 @Override 221 public Stream<T> stream() { 222 return StreamSupport 223 .stream(new Spliterators.AbstractSpliterator<>(Long.MAX_VALUE, 0) { 224 225 private Iterator<T> theIterator; 226 227 private Iterator<T> iterator() { 228 if (theIterator == null) { 229 fill(); 230 theIterator = DefaultFileTree.super.stream().iterator(); 231 } 232 return theIterator; 233 } 234 235 @Override 236 public void forEachRemaining(Consumer<? super T> action) { 237 iterator().forEachRemaining(action); 238 } 239 240 @Override 241 public boolean tryAdvance(Consumer<? super T> action) { 242 if (!iterator().hasNext()) { 243 return false; 244 } 245 action.accept(iterator().next()); 246 return true; 247 } 248 }, false); 249 } 250 251 @Override 252 public FileTree<T> clear() { 253 super.clear(); 254 filled = false; 255 return this; 256 } 257 258 @Override 259 public FileTree<T> delete() { 260 final PathMatcher pathMatcher = FileSystems.getDefault() 261 .getPathMatcher("glob:" + pattern); 262 try { 263 var root = root(); 264 Files.walkFileTree(root, new SimpleFileVisitor<>() { 265 266 @Override 267 public FileVisitResult visitFile(Path path, 268 BasicFileAttributes attrs) throws IOException { 269 if (pathMatcher.matches(path)) { 270 Files.delete(path); 271 } 272 return FileVisitResult.CONTINUE; 273 } 274 275 @Override 276 public FileVisitResult postVisitDirectory(Path dir, 277 IOException exc) throws IOException { 278 if (exc != null) { 279 return FileVisitResult.CONTINUE; 280 } 281 if (!dir.equals(root) 282 && Files.list(dir).findFirst().isEmpty()) { 283 Files.delete(dir); 284 } 285 return FileVisitResult.CONTINUE; 286 } 287 288 @Override 289 public FileVisitResult visitFileFailed(Path file, 290 IOException exc) throws IOException { 291 if (exc instanceof AccessDeniedException) { 292 return FileVisitResult.SKIP_SUBTREE; 293 } 294 return FileVisitResult.CONTINUE; 295 } 296 }); 297 } catch (IOException e) { 298 log.log(java.util.logging.Level.SEVERE, e, 299 () -> "Problem scanning files: " + e.getMessage()); 300 throw new BuildException(e); 301 } 302 filled = false; 303 return this; 304 } 305 306 @Override 307 public Stream<Path> entries() { 308 return stream().map(fr -> root().relativize(fr.path())); 309 } 310 311 @Override 312 public int hashCode() { 313 final int prime = 31; 314 int result = super.hashCode(); 315 result 316 = prime * result + Objects.hash(excludes, pattern, root, withDirs); 317 return result; 318 } 319 320 @Override 321 public boolean equals(Object obj) { 322 if (this == obj) { 323 return true; 324 } 325 if (!super.equals(obj)) { 326 return false; 327 } 328 return (obj instanceof DefaultFileTree other) 329 && Objects.equals(excludes, other.excludes) 330 && Objects.equals(pattern, other.pattern) 331 && Objects.equals(root, other.root) && withDirs == other.withDirs; 332 } 333 334 @Override 335 public String toString() { 336 var wasFilled = filled; 337 fill(); 338 String str = type().toString() + " (" + asOfLocalized() 339 + ") from " + Path.of("").toAbsolutePath().relativize(root()) 340 + " with " + stream().count() + " elements"; 341 if (!wasFilled) { 342 clear(); 343 } 344 filled = wasFilled; 345 return str; 346 } 347}