diff --git a/buildSrc/src/main/groovy/ethylene.library-conventions.gradle b/buildSrc/src/main/groovy/ethylene.library-conventions.gradle index cbceb62..89334f9 100644 --- a/buildSrc/src/main/groovy/ethylene.library-conventions.gradle +++ b/buildSrc/src/main/groovy/ethylene.library-conventions.gradle @@ -5,7 +5,7 @@ plugins { } group 'com.github.steanky' -version '0.20.1' +version '0.21.0' publishing { publications { diff --git a/ethylene-core/src/main/java/com/github/steanky/ethylene/core/Graph.java b/ethylene-core/src/main/java/com/github/steanky/ethylene/core/Graph.java index 5e8f29f..2c57180 100644 --- a/ethylene-core/src/main/java/com/github/steanky/ethylene/core/Graph.java +++ b/ethylene-core/src/main/java/com/github/steanky/ethylene/core/Graph.java @@ -2,6 +2,7 @@ import com.github.steanky.toolkit.collection.Iterators; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.function.Function; @@ -91,7 +92,7 @@ public static TOut process(TIn rootInput, @NotNull Function> nodeFunction, @NotNull Predicate containerPredicate, @NotNull Function scalarMapper, Function visitKeyMapper, - Supplier> visitedSupplier, + Supplier> visitedSupplier, @NotNull Supplier>> stackSupplier, int flags) { if (!containerPredicate.test(rootInput)) { //if rootInput is a scalar, just return whatever the scalar mapper produces @@ -113,10 +114,13 @@ public static TOut process(TIn rootInput, //don't initialize the visitation map if there is no support for circular references //make sure usages of this map check circularRefSupport to avoid NPE - Map visited = null; if (circularRefSupport) { - visited = visitedSupplier.get(); - visited.put(visitKeyMapper.apply(rootInput), rootNode.output.data); + rootNode.identity = visitKeyMapper.apply(rootInput); + } + + Map scalarMap = null; + if (trackScalarReference) { + scalarMap = visitedSupplier.get(); } Deque> stack = stackSupplier.get(); @@ -137,24 +141,25 @@ public static TOut process(TIn rootInput, //nodes that aren't containers have no children, so we can immediately add them to the accumulator if (hasOutput) { TOut out; - boolean circular; + boolean visited; - //keep track of scalar references in the same way as nodes, if enabled - if (circularRefSupport && trackScalarReference) { + if (trackScalarReference) { TVisit visit = visitKeyMapper.apply(entryInput); - if (visited.containsKey(visit)) { - out = visited.get(visit); - circular = true; - } else { - out = scalarMapper.apply(entryInput); - circular = false; + if (scalarMap.containsKey(visit)) { + out = scalarMap.get(visit); + visited = true; + } + else { + scalarMap.put(visit, out = scalarMapper.apply(entryInput)); + visited = false; } - } else { + } + else { out = scalarMapper.apply(entryInput); - circular = false; + visited = false; } - node.output.accumulator.accept(entryKey, out, circular); + node.output.accumulator.accept(entryKey, out, visited); } continue; @@ -166,7 +171,7 @@ public static TOut process(TIn rootInput, visit = visitKeyMapper.apply(entryInput); //check containsKey, null values are allowed in the map - if (visited.containsKey(visit)) { + if (node.hasParent(visit)) { /* already-visited references are immediately added to the accumulator. if these references are nodes, their output might not have been fully constructed yet. it might not even be possible to @@ -174,7 +179,7 @@ public static TOut process(TIn rootInput, them to the accumulator, and let it know the reference is circular */ if (hasOutput) { - node.output.accumulator.accept(entryKey, visited.get(visit), true); + node.output.accumulator.accept(entryKey, node.getParent(visit), true); } continue; @@ -183,7 +188,8 @@ public static TOut process(TIn rootInput, Node newNode = nodeFunction.apply(entryInput); if (circularRefSupport) { - visited.put(visit, newNode.output.data); + newNode.parent = node; + newNode.identity = visit; } if (isEmpty(newNode)) { @@ -369,9 +375,9 @@ public interface Accumulator { * * @param key the key component of the value * @param out the value component - * @param visited whether this input has been visited before + * @param circular whether this input is a circular reference */ - void accept(TKey key, TOut out, boolean visited); + void accept(TKey key, TOut out, boolean circular); } /** @@ -444,12 +450,41 @@ public static final class Node { private NodeResult result; + private Node parent; + private Object identity; + private Node(@NotNull Iterator> inputIterator, @NotNull Output output) { this.inputIterator = inputIterator; this.output = output; } + private TOut getParent(@Nullable Object identity) { + Node current = this; + while (current != null) { + if (current.identity == identity) { + return current.output.data; + } + + current = current.parent; + } + + return null; + } + + private boolean hasParent(@Nullable Object identity) { + Node current = this; + while (current != null) { + if (current.identity == identity) { + return true; + } + + current = current.parent; + } + + return false; + } + private boolean hasResult() { return result != null; } diff --git a/ethylene-core/src/main/java/com/github/steanky/ethylene/core/collection/ConfigContainers.java b/ethylene-core/src/main/java/com/github/steanky/ethylene/core/collection/ConfigContainers.java index 3a64bf7..6e948d6 100644 --- a/ethylene-core/src/main/java/com/github/steanky/ethylene/core/collection/ConfigContainers.java +++ b/ethylene-core/src/main/java/com/github/steanky/ethylene/core/collection/ConfigContainers.java @@ -89,7 +89,7 @@ final class ConfigContainers { private int i; @Override - public void accept(String s, ConfigElement element, boolean visited) { + public void accept(String s, ConfigElement element, boolean circular) { underlyingArray[i++] = element; } }); diff --git a/ethylene-core/src/main/java/com/github/steanky/ethylene/core/loader/DirectoryTreeConfigSource.java b/ethylene-core/src/main/java/com/github/steanky/ethylene/core/loader/DirectoryTreeConfigSource.java deleted file mode 100644 index 61a8ebf..0000000 --- a/ethylene-core/src/main/java/com/github/steanky/ethylene/core/loader/DirectoryTreeConfigSource.java +++ /dev/null @@ -1,309 +0,0 @@ -package com.github.steanky.ethylene.core.loader; - -import com.github.steanky.ethylene.core.ConfigCodec; -import com.github.steanky.ethylene.core.ConfigElement; -import com.github.steanky.ethylene.core.ConfigPrimitive; -import com.github.steanky.ethylene.core.Graph; -import com.github.steanky.ethylene.core.bridge.ConfigSource; -import com.github.steanky.ethylene.core.bridge.Configuration; -import com.github.steanky.ethylene.core.collection.ConfigEntry; -import com.github.steanky.ethylene.core.collection.ConfigNode; -import com.github.steanky.ethylene.core.collection.Entry; -import com.github.steanky.ethylene.core.collection.LinkedConfigNode; -import com.github.steanky.ethylene.core.util.FutureUtils; -import com.github.steanky.toolkit.function.ExceptionHandler; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.nio.file.FileVisitOption; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * A specialized {@link ConfigSource} implementation based off of a filesystem, which may consist of any number of - * nested directories, multiple files, etc. when given a root {@link Path} to use. If this root path is a file, it will - * be read to and written from normally. Reads and writes may occur synchronously or asynchronously. If an - * {@link Executor} is provided when constructing this instance, it will be used to asynchronously perform reads and - * writes. If {@code null} is passed instead, reads and writes will proceed synchronously. - */ -public class DirectoryTreeConfigSource implements ConfigSource { - private static final LinkOption[] EMPTY_LINK_OPTION_ARRAY = new LinkOption[0]; - private static final LinkOption[] NOFOLLOW_LINKS = new LinkOption[]{LinkOption.NOFOLLOW_LINKS}; - private static final FileVisitOption[] EMPTY_FILE_VISIT_OPTION_ARRAY = new FileVisitOption[0]; - private static final FileVisitOption[] FOLLOW_LINKS = new FileVisitOption[]{FileVisitOption.FOLLOW_LINKS}; - - private final Path rootPath; - private final CodecResolver codecResolver; - private final PathInspector pathNameInspector; - private final ConfigCodec preferredCodec; - private final String preferredExtensionName; - private final String preferredExtension; - private final Executor executor; - private final boolean supportSymlinks; - private final FileVisitOption[] fileVisitOptions; - - /** - * Creates a new instance of this class. - * - * @param rootPath the root folder or file - * @param codecResolver the {@link CodecResolver} used to find codecs for specific files - * @param pathInspector the {@link PathInspector} used to extract path names and extensions - * @param preferredCodec the {@link ConfigCodec} to use when writing files that do not already exist - * @param executor the {@link Executor} to use to asynchronously read and write files; when null, reads and - * writes will proceed asynchronously - * @param supportSymlinks whether to read and create symbolic links when faced with circular/duplicate references in - * the input data or file hierarchy - */ - public DirectoryTreeConfigSource(@NotNull Path rootPath, @NotNull CodecResolver codecResolver, - @NotNull PathInspector pathInspector, @NotNull ConfigCodec preferredCodec, @Nullable Executor executor, - boolean supportSymlinks) { - this.rootPath = Objects.requireNonNull(rootPath); - this.codecResolver = Objects.requireNonNull(codecResolver); - this.pathNameInspector = Objects.requireNonNull(pathInspector); - this.preferredCodec = Objects.requireNonNull(preferredCodec); - - this.preferredExtensionName = preferredCodec.getPreferredExtension(); - this.preferredExtension = - preferredExtensionName.isEmpty() ? preferredExtensionName : "." + preferredExtensionName; - this.executor = executor; - this.supportSymlinks = supportSymlinks; - this.fileVisitOptions = supportSymlinks ? FOLLOW_LINKS : EMPTY_FILE_VISIT_OPTION_ARRAY; - } - - private static Object getKey(Path path, boolean followSymlinks) { - try { - //try to use file keys if possible (supported by the system & accessible by us) - //readAttributes follows symlinks (so a symlink to a file and the file itself will have the same key) - LinkOption[] options = followSymlinks ? EMPTY_LINK_OPTION_ARRAY : NOFOLLOW_LINKS; - Object fileKey = Files.readAttributes(path, BasicFileAttributes.class, options).fileKey(); - if (fileKey != null) { - return fileKey; - } - } catch (IOException ignored) { - //if we can't read the attributes, we probably can't follow it if it's a symlink, so just return - return path.toAbsolutePath().normalize().toString(); - } - - if (!followSymlinks) { - //symlinks disabled - return path.toAbsolutePath().normalize().toString(); - } - - //if we can't use the file key because system support is lacking, make a best-effort attempt to create a key - //from the path string, following symbolic links as necessary, with cycle detection, taking care to normalize - //the path for consistent behavior - path = path.toAbsolutePath().normalize(); - if (Files.isSymbolicLink(path)) { - //low initial capacity, we shouldn't expect too many nested symlinks - Set visited = new HashSet<>(2); - visited.add(path); - - do { - try { - path = Files.readSymbolicLink(path).toAbsolutePath().normalize(); - } catch (IOException e) { - return path.toString(); - } - - if (!visited.add(path)) { - //cycle detected - return path.toString(); - } - } while (Files.isSymbolicLink(path)); - } - - return path.toString(); - } - - @Override - public @NotNull CompletableFuture read() { - return FutureUtils.completeCallable(() -> { - if (!Files.exists(rootPath)) { - return ConfigPrimitive.NULL; - } - - try (ExceptionHandler exceptionHandler = new ExceptionHandler<>(IOException.class)) { - return Graph.process(rootPath, directoryEntry -> { - //gets all files that are directories, not the current path, or a file with an extension we can - //understand - List pathList = exceptionHandler.get(() -> { - try (Stream paths = Files.walk(directoryEntry, 1, fileVisitOptions)) { - return paths.filter(path -> filterPath(directoryEntry, path)).toList(); - } - }, List::of); - - ConfigNode node = new LinkedConfigNode(pathList.size()); - return Graph.node(new Iterator<>() { - private final Iterator pathIterator = pathList.listIterator(); - - @Override - public boolean hasNext() { - return pathIterator.hasNext(); - } - - @Override - public Map.Entry next() { - Path path = pathIterator.next(); - return Entry.of(pathNameInspector.getName(path), path); - } - }, Graph.output(node, - (Graph.Accumulator) (s, configElement, circular) -> node.put(s, - configElement))); - }, Files::isDirectory, entry -> { - String extension = pathNameInspector.getExtension(entry); - if (codecResolver.hasCodec(extension)) { - return exceptionHandler.get(() -> Configuration.read(entry, - codecResolver.resolve(extension)), - () -> ConfigPrimitive.NULL); - } - - return ConfigPrimitive.NULL; - }, entry -> getKey(entry, supportSymlinks), HashMap::new, ArrayDeque::new, - Graph.Options.TRACK_ALL_REFERENCES); - } - }, executor); - } - - @Override - public @NotNull CompletableFuture write(@NotNull ConfigElement element) { - return FutureUtils.completeCallable(() -> { - if (!Files.exists(rootPath)) { - //create the root if it doesn't exist - Files.createDirectories(rootPath); - } - - if (!Files.isDirectory(rootPath) || !element.isNode()) { - //if root is not a directory, write the entire element to it - //if element is not a node, do the same - String extension = pathNameInspector.getExtension(rootPath); - Configuration.write(rootPath, element, - codecResolver.hasCodec(extension) ? codecResolver.resolve(extension) : preferredCodec); - return null; - } - - try (ExceptionHandler exceptionHandler = new ExceptionHandler<>(IOException.class)) { - Graph.process(new OutputInfo(rootPath, element, true), containerEntry -> { - exceptionHandler.run(() -> Files.createDirectories(containerEntry.path)); - Path normalizedPath = containerEntry.path.normalize(); - - //get paths currently in the folder - //if there's an exception, existingPaths will be empty - Set existingPaths = exceptionHandler.get(() -> { - try (Stream paths = Files.walk(normalizedPath, 1, fileVisitOptions)) { - //like when reading, include all directories, and ignore files whose extensions we don't - //recognize - return paths.filter(path -> filterPath(normalizedPath, path)).collect(Collectors.toSet()); - } - }, Set::of); - - ConfigNode node = containerEntry.element.asNode(); - - //list of paths - //key (first in the entry) is always null because it's unused - List> paths = new ArrayList<>(node.size()); - for (ConfigEntry entry : node.entryCollection()) { - String elementName = entry.getKey(); - ConfigElement entryElement = entry.getValue(); - Path targetPath = normalizedPath.resolve(elementName); - - boolean exists = existingPaths.contains(targetPath); - - if (exists) { - //path already exists, is either an extensionless file or directory - //if it's a directory, we'll iterate into it later - //if it's a file, we'll write to it as a scalar - paths.add(Entry.of(null, - new OutputInfo(targetPath, entryElement, Files.isDirectory(targetPath)))); - } else { - //path doesn't exist, but that could be because of an extension - //filter the existing paths to those whose name matches - List targets = existingPaths.stream() - .filter(path -> pathNameInspector.getName(path).equals(elementName)).toList(); - - if (targets.isEmpty()) { - //no file found at all - we'll write a new one using our preferred extension - Path filePath = normalizedPath.resolve(elementName + preferredExtension); - paths.add(Entry.of(null, new OutputInfo(filePath, entryElement, false))); - } else { - //we have at least one matching (and existing) file - //if there are multiple files, they must differ only by extension - //if one of these extensions is the preferred extension, use that - Path preferred = null; - for (Path path : targets) { - String extension = pathNameInspector.getExtension(path); - if (preferredExtensionName.equals(extension)) { - preferred = path; - break; - } - } - - if (preferred == null) { - //no preferred extension, just use the first file on the list - preferred = targets.get(0); - } - - paths.add(Entry.of(null, new OutputInfo(preferred, entryElement, false))); - } - } - } - - //use another iterator - this will differ from the results passed to the accumulator only when - //there is a circular reference (in which case the OutputInfo passed to the accumulator comes - //from some other node) - Iterator> actualIterator = paths.iterator(); - return Graph.node(paths.iterator(), Graph.output(containerEntry, (o, outputInfo, circular) -> { - //actually write the files - //need to do this here, instead of in, say, the scalar mapper, so we can create - //symlinks when indicated to do so by the circular flag - OutputInfo actualInfo = actualIterator.next().getValue(); - - if (circular) { - //make a symlink to the target file/directory instead of making a copy - //strip the extension if the link target is a directory - Path link = outputInfo.isDirectory ? - actualInfo.path.getParent().resolve(pathNameInspector.getName(actualInfo.path)) : - actualInfo.path; - - exceptionHandler.run(() -> Files.createSymbolicLink(link, outputInfo.path)); - } else if (!outputInfo.isDirectory) { - //if outputInfo is a directory, no need to bother writing anything - //(it will be created when its appropriate node is initialized) - String extension = pathNameInspector.getExtension(actualInfo.path); - - exceptionHandler.run(() -> Configuration.write(actualInfo.path, actualInfo.element, - codecResolver.hasCodec(extension) ? codecResolver.resolve(extension) : preferredCodec)); - } - - //we may end up writing nothing if: symlink support is disabled, and we're trying to write a - //directory - })); - }, potentialContainer -> { - if (!potentialContainer.element.isNode()) { - return false; - } - - return potentialContainer.isDirectory; - }, Function.identity(), entry -> entry.element, Graph.Options.TRACK_ALL_REFERENCES); - - return null; - } - }, executor); - } - - private boolean filterPath(Path rootPath, Path childPath) { - return !childPath.equals(rootPath) && - (Files.isDirectory(childPath) || codecResolver.hasCodec(pathNameInspector.getExtension(childPath))); - } - - private record OutputInfo(Path path, ConfigElement element, boolean isDirectory) { - } -} \ No newline at end of file diff --git a/ethylene-core/src/test/java/com/github/steanky/ethylene/core/loader/DirectoryTreeConfigSourceTest.java b/ethylene-core/src/test/java/com/github/steanky/ethylene/core/loader/DirectoryTreeConfigSourceTest.java deleted file mode 100644 index 73969b3..0000000 --- a/ethylene-core/src/test/java/com/github/steanky/ethylene/core/loader/DirectoryTreeConfigSourceTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.steanky.ethylene.core.loader; - -import com.github.steanky.ethylene.codec.json.JsonCodec; -import com.github.steanky.ethylene.core.ConfigElement; -import com.github.steanky.ethylene.core.collection.ConfigNode; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertSame; - -class DirectoryTreeConfigSourceTest { - @SuppressWarnings("CollectionAddedToSelf") - @Test - void readWriteEquivalent() throws IOException { - ConfigNode testNode = ConfigNode.of("root", - ConfigNode.of("children", ConfigNode.of("children", ConfigNode.of("key", "value")), "children2", - ConfigNode.of("key2", "value2"))); - testNode.put("reference", testNode); - - JsonCodec jsonCodec = new JsonCodec(); - RegistrableCodecResolver codecResolver = new RegistrableCodecResolver(); - codecResolver.registerCodec(jsonCodec); - - Path root = Files.createTempDirectory("DirectoryTreeConfigSourceTest_output"); - DirectoryTreeConfigSource directoryTreeConfigSource = - new DirectoryTreeConfigSource(root, codecResolver, BasicPathInspector.INSTANCE, jsonCodec, null, true); - directoryTreeConfigSource.write(testNode).join(); - - ConfigElement element = directoryTreeConfigSource.read().join(); - assertSame(element, element.getElement("reference")); - } -} \ No newline at end of file diff --git a/ethylene-mapper/src/main/java/com/github/steanky/ethylene/mapper/MappingConfigProcessor.java b/ethylene-mapper/src/main/java/com/github/steanky/ethylene/mapper/MappingConfigProcessor.java index 1cc1cac..58377ce 100644 --- a/ethylene-mapper/src/main/java/com/github/steanky/ethylene/mapper/MappingConfigProcessor.java +++ b/ethylene-mapper/src/main/java/com/github/steanky/ethylene/mapper/MappingConfigProcessor.java @@ -152,8 +152,8 @@ public Map.Entry next() { private int i; @Override - public void accept(Object key, Mutable value, boolean visited) { - if (visited && !signature.hasBuildingObject()) { + public void accept(Object key, Mutable value, boolean circular) { + if (circular && !signature.hasBuildingObject()) { throw new MapperException("Signatures which do not supply building objects may " + "not be used to construct circular references"); }