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}