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}