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}