octavian-nita
5/6/2019 - 9:45 AM

Simple/general Path implementation

package ...;

import lombok.*;

import java.util.*;
import java.util.function.BiConsumer;
import java.util.regex.Pattern;

import static java.util.Objects.*;

/**
 * Models a general path made of nodes to which we can optionally attach values and URLs.
 * <p/>
 * Note that while {@link Node path node} instances {@link Node#setValue(String) are} {@link Node#setRefUri(String)
 * mutable} and do not define an identity, we can easily imagine a {@link #getFullPath() full path} identity.
 *
 * @param <SELF> the actual path (sub)type in order to leverage a fluent style API
 * @author <a href="mailto:octavian.nita@gmail.com">Octavian Nita</a>
 * @version 1.0, 2019/05/05
 * @see <a href="https://stackoverflow.com/a/23895571/272939">Answer to <em>Fluent API with inheritance and generics</em></a>
 * @see <a href="https://stackoverflow.com/a/7355094/272939">Answer to <em>Is there a way to refer to the current type
 * with a type variable?</em></a>
 */
@Getter
@ToString
public class Path<SELF extends Path<SELF>> {

    /**
     * @see <a href="https://stackoverflow.com/a/23895571/272939">Answer to <em>Fluent API with inheritance and
     * generics</em></a>
     * @see <a href="https://stackoverflow.com/a/7355094/272939">Is there a way to refer to the current type with a type
     */
    @SuppressWarnings("unchecked")
    protected final SELF self() {
        return (SELF) this;
    }

    /**
     * Eventual prefix of the path specified by the added nodes. Can be {@code null}.
     */
    private final String parentPath;

    /**
     * Path nodes that come after the {@link #parentPath}, if the latter is not {@code null}.
     */
    private final List<Node> pathNodes = new LinkedList<>();

    /**
     * Path (node) separator.
     */
    protected static final String separator = "/";

    /**
     * Path (node) separator as (escaped) regular expression.
     */
    protected static final String separatorRegex = "\\Q" + separator + "\\E";

    @NonNull
    protected List<Node> getPathNodes() { return new LinkedList<>(pathNodes); }

    public Path(String parentPath) {
        this.parentPath = parentPath != null && parentPath.endsWith(separator)
                          ? parentPath .substring(0, parentPath.length() - separator.length())
                          : parentPath;
    }

    public Path() {
        this(null);
    }

    public SELF add(@NonNull String nodeName) {
        pathNodes.add(new Node(nodeName));
        return self();
    }

    public SELF add(@NonNull String nodeName, String value) {
        pathNodes.add(new Node(nodeName, value));
        return self();
    }

    public SELF add(@NonNull String nodeName, String value, String refUri) {
        pathNodes.add(new Node(nodeName, value, refUri));
        return self();
    }

    /**
     * @return the full path of the path (usually a convenience) or the empty string if no parent path and no nodes
     * are defined
     */
    @NonNull
    public String getFullPath() {
        final StringBuilder fullPath = parentPath == null ? new StringBuilder() : new StringBuilder(parentPath);

        for (Node currentNode : pathNodes) {
            if (fullPath.length() > 0) {
                fullPath.append(separator);
            }
            fullPath.append(currentNode.getName());
        }

        return fullPath.toString();
    }

    /**
     * Invokes {@code action} for each path node, passing in the node as well as the current path to it.
     *
     * @return the full path of the path (usually a convenience) or the empty string if no parent path and no nodes
     * are defined
     */
    @NonNull
    public String forEachPathNode(BiConsumer<? super String, ? super Node> action) {
        requireNonNull(action);

        if (pathNodes.isEmpty()) {
            return "";
        }

        final StringBuilder currentPath = parentPath == null ? new StringBuilder() : new StringBuilder(parentPath);
        for (Node currentNode : pathNodes) {
            if (currentPath.length() > 0) {
                currentPath.append(separator);
            }
            currentPath.append(currentNode.getName());

            action.accept(currentPath.toString(), currentNode);
        }

        return currentPath.toString();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }

        if (!(o instanceof Path)) {
            return false;
        }

        final Path<?> path = (Path<?>) o;

        if (!Objects.equals(parentPath, path.parentPath) || pathNodes.size() != path.pathNodes.size()) {
            return false;
        }

        final int nodeCount = pathNodes.size();
        for (int i = 0; i < nodeCount; i++) {
            if (!pathNodes.get(i).getName().equals(path.pathNodes.get(0).getName())) {
                return false;
            }
        }

        return true;
    }

    @Override
    public int hashCode() {
        int hash = hash(parentPath);
        for (Node pathNode : pathNodes) {
            hash = hash(hash, pathNode.getName());
        }
        return hash;
    }

    @Getter
    @Setter
    @ToString
    public static class Node {

        @NonNull
        private final String name;

        private String value;

        private String refUri;

        /**
         * By default, the {@link #getValue() value} is also set to {@code name} (although un-{@link
         * #sanitizeAsName(String)
         * sanitized}).
         */
        protected Node(@NonNull String name) { this(name, name); }

        protected Node(@NonNull String name, String value) {
            this.name = sanitizeAsName(name);
            this.value = value;
        }

        protected Node(@NonNull String name, String value, String refUri) {
            this(name, value);
            this.refUri = refUri;
        }

        public static String sanitizeAsName(String name) {
            return name == null ? null : INVALID_NAME_CHARS.matcher(name).replaceAll("_");
        }

        private static final Pattern INVALID_NAME_CHARS = Pattern.compile("[^a-zA-Z0-9/_-]+");
    }
}