TreeConf.java

package swingtree;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sprouts.Association;
import sprouts.HasId;
import sprouts.Pair;
import sprouts.Tuple;
import swingtree.api.Configurator;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

/**
 *  Describes the shape of a tree: which node types have children, where those children
 *  live inside the node, how a node is labelled, and what identifies it.
 *  <p>
 *  A {@link javax.swing.JTree} in SwingTree is bound to a single property holding a
 *  deeply immutable, nested data structure. This class is how you tell the tree which
 *  parts of that structure to zoom into:
 *  <pre>{@code
 *  public sealed interface FsNode extends HasId<UUID> { String name(); }
 *  public record Dir( UUID id, String name, Tuple<FsNode> entries ) implements FsNode {}
 *  public record Doc( UUID id, String name, String body )           implements FsNode {}
 *
 *  UI.tree(fileSystem, conf -> conf
 *      .nodesOf(Dir.class, dir -> dir.children(Dir::entries).text(Dir::name))
 *      .nodesOf(Doc.class, doc -> doc.text(Doc::name))
 *  );
 *  }</pre>
 *  Each {@link #nodesOf(Class, Configurator)} block covers one node type and reads like one
 *  {@code case} of the {@code switch} you would otherwise write by hand, which is why this
 *  API pairs so naturally with a sealed interface based sum type.
 *  <p>
 *  <b>On identity.</b> A tree of value objects has a problem a list of them does not:
 *  {@link javax.swing.JTree} keys expansion and selection on {@link javax.swing.tree.TreePath},
 *  which compares nodes with {@link Object#equals(Object)}. Records compare by content, so
 *  editing one leaf would invalidate every path in the tree at once. SwingTree therefore
 *  identifies a node by its <i>path of ids</i>. Nodes implementing {@link HasId} supply that
 *  id for free; for types you do not own, declare one with {@link #idOf(Function)}. Ids only
 *  need to be unique <i>among siblings</i>, because the path disambiguates the rest.
 *  <p>
 *  A configuration describes node types, not the shape of the top level, so the very same
 *  value binds a single rooted tree through {@link UI#tree(sprouts.Var, TreeConf)} and a
 *  forest of them through {@link UI#trees(sprouts.Var, TreeConf)}.
 *  <p>
 *  Instances of this class are immutable values, so every method returns a new
 *  instance instead of modifying the receiver.
 *
 * @param <I> The identity type of the nodes, which selection paths are made of.
 * @param <N> The common node type of the tree, typically a sealed interface.
 */
public final class TreeConf<I, N>
{
    private static final Logger log = LoggerFactory.getLogger(TreeConf.class);

    private final Class<N>                                    _nodeType;
    private final @Nullable Class<I>                          _idType;
    private final Association<Class<?>, TreeNodeConf<N, ?>>   _rules;
    private final @Nullable Function<N, I>                    _idGetter;
    private final boolean                                     _leafWhenEmpty;

    /*
        Caches whose content is fully determined by the immutable fields above. Concurrent
        because a lens resolves rules on the application thread while the tree paints rows
        on the Event Dispatch Thread.
    */
    private final Map<Class<?>, Object> _ruleMemo     = new ConcurrentHashMap<>();
    private final Set<Class<?>>         _warnedAbout  = ConcurrentHashMap.newKeySet();

    /**
     *  Declares the shape of a tree whose nodes carry their own identity through
     *  {@link HasId}, which is the usual case. The result is an ordinary immutable value:
     *  bind it to a tree with {@link UI#tree(sprouts.Var, TreeConf)}, and ask it questions
     *  about paths with {@link #nodeAt(Object, Tuple)} and {@link #nodesAlong(Object, Tuple)}.
     *  <pre>{@code
     *  TreeConf<String, Packed> shape = TreeConf.of(Packed.class)
     *          .nodesOf(Box.class,  it -> it.children(Box::contents).text(Box::label))
     *          .nodesOf(Item.class, it -> it.text(Item::label));
     *  }</pre>
     *
     * @param nodeType The common supertype of every node in the tree.
     * @param <I> The identity type of the nodes, taken from the {@link HasId} bound.
     * @param <N> The common node type of the tree.
     * @return A new, empty tree configuration.
     */
    public static <I, N extends HasId<I>> TreeConf<I, N> of( Class<N> nodeType ) {
        Objects.requireNonNull(nodeType, "nodeType");
        return _of(nodeType, null);
    }

    /**
     *  Declares the shape of a tree whose node types cannot implement {@link HasId}, by
     *  naming the identity type explicitly. Their identity is then declared with
     *  {@link #idOf(Function)}.
     *
     * @param nodeType The common supertype of every node in the tree.
     * @param idType The type of the node identities, which selection paths are made of.
     * @param <I> The identity type of the nodes.
     * @param <N> The common node type of the tree.
     * @return A new, empty tree configuration.
     */
    public static <I, N> TreeConf<I, N> of( Class<N> nodeType, Class<I> idType ) {
        Objects.requireNonNull(nodeType, "nodeType");
        Objects.requireNonNull(idType, "idType");
        return _of(nodeType, idType);
    }

    @SuppressWarnings({"unchecked", "rawtypes"})
    static <I, N> TreeConf<I, N> _of( Class<N> nodeType, @Nullable Class<I> idType ) {
        return new TreeConf<>(
            nodeType,
            idType,
            (Association) Association.betweenLinked(Class.class, TreeNodeConf.class),
            null,
            false
        );
    }

    private TreeConf(
        Class<N>                                  nodeType,
        @Nullable Class<I>                        idType,
        Association<Class<?>, TreeNodeConf<N, ?>> rules,
        @Nullable Function<N, I>                  idGetter,
        boolean                                   leafWhenEmpty
    ) {
        _nodeType      = Objects.requireNonNull(nodeType);
        _idType        = idType;
        _rules         = Objects.requireNonNull(rules);
        _idGetter      = idGetter;
        _leafWhenEmpty = leafWhenEmpty;
    }

    /**
     *  Declares how nodes of the given type behave: whether they have children and where,
     *  how they are labelled, and what icon they carry. Declaring a block for a type that
     *  already has one replaces the previous block.
     *  <pre>{@code
     *  UI.tree(fileSystem, conf -> conf
     *      .nodesOf(Dir.class, dir -> dir
     *          .children(Dir::entries, Dir::withEntries)
     *          .text(Dir::name, Dir::withName)
     *      )
     *  );
     *  }</pre>
     *  A node whose type matches no block at all is treated as a leaf labelled by its
     *  {@link Object#toString()}, and SwingTree logs a warning naming the type, because
     *  that is almost always a forgotten case rather than an intent.
     *  <p>
     *  Where several blocks match a node, the most specific one wins and is used <i>on its
     *  own</i>: a block is chosen, never merged with a more general one, in the same way one
     *  {@code case} of a {@code switch} does not continue into another. A block which wants
     *  the label of a catch-all block above it therefore has to declare that label too.
     *
     * @param type The concrete node type this block applies to.
     * @param conf Configures the behaviour of nodes of that type.
     * @param <B> The concrete node type.
     * @return An updated configuration.
     */
    public <B extends N> TreeConf<I, N> nodesOf( Class<B> type, Configurator<TreeNodeConf<N, B>> conf ) {
        Objects.requireNonNull(type, "type");
        Objects.requireNonNull(conf, "conf");
        TreeNodeConf<N, B> nodeConf = TreeNodeConf.of(type);
        try {
            nodeConf = conf.configure(nodeConf);
        } catch (Exception e) {
            log.error(SwingTree.get().logMarker(),
                    "Failed to configure tree nodes of type '{}'.", type.getSimpleName(), e);
            return this;
        }
        Objects.requireNonNull(nodeConf);
        return new TreeConf<>(_nodeType, _idType, _rules.put(type, nodeConf), _idGetter, _leafWhenEmpty);
    }

    /**
     *  Declares how <i>every</i> node behaves, which is the right thing when the tree is
     *  homogeneous and one rule covers all of it. It is exactly
     *  {@code nodesOf(theNodeType, conf)}, so a more specific block declared for a subtype
     *  wins over it — and, because the winning block is used on its own, replaces it rather
     *  than adding to it. A subtype which wants what is declared here has to restate it.
     *
     * @param conf Configures the behaviour of all nodes.
     * @return An updated configuration.
     */
    public TreeConf<I, N> nodesOf( Configurator<TreeNodeConf<N, N>> conf ) {
        return nodesOf(_nodeType, conf);
    }

    /**
     *  Declares what identifies a node, which is what lets expansion and selection survive
     *  an edit anywhere in the tree. Node types implementing {@link HasId} need no such
     *  declaration; use this one for types you do not own:
     *  <pre>{@code
     *  .idOf( node -> node instanceof Department ? ((Department) node).id() : node )
     *  }</pre>
     *  Ids only have to be unique among the children of one parent.
     *
     * @param id Produces the identity of a node.
     * @return An updated configuration.
     */
    public TreeConf<I, N> idOf( Function<N, I> id ) {
        Objects.requireNonNull(id, "id");
        return new TreeConf<>(_nodeType, _idType, _rules, id, _leafWhenEmpty);
    }

    /**
     *  Decides whether a branch that currently has no children should be drawn as a leaf.
     *  SwingTree defaults to {@code false}, meaning the presence of a {@code children(..)}
     *  rule alone makes a node a branch, so an empty folder still looks like a folder.
     *  Pass {@code true} for the plain {@link javax.swing.JTree} behaviour, where a node
     *  with zero children is a leaf.
     *
     * @param leafWhenEmpty True to draw childless branches as leaves.
     * @return An updated configuration.
     */
    public TreeConf<I, N> leafWhenEmpty( boolean leafWhenEmpty ) {
        return new TreeConf<>(_nodeType, _idType, _rules, _idGetter, leafWhenEmpty);
    }

    /**
     *  Resolves a selection path back to the node it names, which is what a view bound to
     *  a selection needs in order to show anything about what is selected.
     *  <p>
     *  A selection is a {@link Tuple} of ids leading down from the root, because that is
     *  the only thing which identifies a <i>position</i> in a tree. The node living at that
     *  position is a question about the tree, and this is how you ask it.
     *  <p>
     *  Declaring the shape as an ordinary value rather than inline is what makes that
     *  elegant, because the same value then serves twice: it builds the tree, and it answers
     *  the detail pane standing next to the tree.
     *  <pre>{@code
     *  private final Var<FsNode>        fileSystem   = vm.zoomTo(Workspace::files,  Workspace::withFiles);
     *  private final Var<Tuple<String>> selectedPath = vm.zoomTo(Workspace::opened, Workspace::withOpened);
     *
     *  private final TreeConf<String, FsNode> shape =
     *          TreeConf.of(FsNode.class)
     *                  .nodesOf(Dir.class, it -> it.children(Dir::entries).text(Dir::name))
     *                  .nodesOf(Doc.class, it -> it.text(Doc::name));
     *
     *  // "Which node is selected" is a function of the tree and the path, so it is derived:
     *  private final Val<FsNode> selectedNode =
     *          Viewable.of(fileSystem, selectedPath,
     *                  (root, path) -> shape.nodeAt(root, path).orElse(null));
     *
     *  UI.panel("fill")
     *  .add("grow, w 35%",
     *      UI.scrollPane().add(
     *          UI.tree(fileSystem, shape)     // the shape builds the tree...
     *          .withSelection(selectedPath)   // ...which reports a path of ids in here...
     *      )
     *  )
     *  .add("grow, w 65%",
     *      UI.panel("fill").add(selectedNode, this::detailsOf)   // ...which answers this pane
     *  );
     *  }</pre>
     *  Note that no listener appears anywhere above, and nothing reaches into the
     *  {@link javax.swing.JTree}. Clicking a row writes a path into {@code selectedPath}, which
     *  makes {@code selectedNode} a different node, which swaps the view in the right hand
     *  panel — three pure functions of one property each.
     *  <p>
     *  Use {@link #nodeAt(Tuple, Tuple)} instead where the tree was bound with
     *  {@link UI#trees(sprouts.Var, swingtree.api.Configurator)}, whose property holds a
     *  {@link Tuple} of top level nodes rather than one root.
     *  <p>
     *  The result is empty for the empty path (nothing is selected), for a path which no
     *  longer leads anywhere, which is what a path pointing into a since-deleted branch does,
     *  and for a root which is {@code null}, which is what a property holding nothing is.
     *
     * @param root The root value of the tree, which is what the path is relative to,
     *             or {@code null} where the property holding it is empty.
     * @param path The ids leading from the root down to the node, the root's own id first.
     * @return The node at that path, or an empty optional if the path names nothing.
     */
    public Optional<N> nodeAt( @Nullable N root, Tuple<I> path ) {
        Tuple<N> along = nodesAlong(root, path);
        return ( along.isEmpty() ? Optional.empty() : Optional.of(along.last()) );
    }

    /**
     *  Resolves a selection path of a <b>forest</b> back to the node it names, which is the
     *  same question {@link #nodeAt(Object, Tuple)} answers for a single rooted tree. A forest
     *  has no root, so the first id of the path names one of the top level nodes rather than a
     *  container above them, and this overload therefore takes the whole top level.
     *  <p>
     *  It is the detail pane of {@link #nodeAt(Object, Tuple)} with one property widened, and
     *  nothing else about the view changes:
     *  <pre>{@code
     *  private final Var<Tuple<FsNode>> projects     = vm.zoomTo(Workspace::open,   Workspace::withOpen);
     *  private final Var<Tuple<String>> selectedPath = vm.zoomTo(Workspace::opened, Workspace::withOpened);
     *
     *  private final TreeConf<String, FsNode> shape =
     *          TreeConf.of(FsNode.class)
     *                  .nodesOf(Dir.class, it -> it.children(Dir::entries).text(Dir::name))
     *                  .nodesOf(Doc.class, it -> it.text(Doc::name));
     *
     *  // The node type has to be named here, because the property no longer has it:
     *  private final Val<FsNode> selectedNode =
     *          Viewable.of(FsNode.class, projects, selectedPath,
     *                  (roots, path) -> shape.nodeAt(roots, path).orElse(null));
     *
     *  UI.panel("fill")
     *  .add("grow, w 35%",
     *      UI.scrollPane().add(
     *          UI.trees(projects, shape)      // several top level nodes, no root drawn
     *          .withSelection(selectedPath)   // [ "myapp", "src", "App.java" ]
     *      )
     *  )
     *  .add("grow, w 65%",
     *      UI.panel("fill").add(selectedNode, this::detailsOf)
     *  );
     *  }</pre>
     *  The one line worth pausing on is the {@code FsNode.class} in the middle. The three
     *  argument {@code Viewable.of(..)} takes its result type from the first property, which
     *  here holds a {@code Tuple<FsNode>} and not an {@code FsNode}, so the four argument form
     *  is the one a forest needs.
     *
     * @param roots The top level nodes of the forest, which is what the path is relative to.
     * @param path The ids leading from a top level node down to the node, its own id first.
     * @return The node at that path, or an empty optional if the path names nothing.
     */
    public Optional<N> nodeAt( Tuple<N> roots, Tuple<I> path ) {
        Tuple<N> along = nodesAlong(roots, path);
        return ( along.isEmpty() ? Optional.empty() : Optional.of(along.last()) );
    }

    /**
     *  Resolves a selection path to every node along it, the root first and the named node
     *  last, which is what a breadcrumb trail is made of. Where
     *  {@link #nodeAt(Object, Tuple)} answers "what is selected", this answers "how did we get
     *  there", and a bar above the tree is the usual reason to ask:
     *  <pre>{@code
     *  private final Var<FsNode>        fileSystem   = vm.zoomTo(Workspace::files,  Workspace::withFiles);
     *  private final Var<Tuple<String>> selectedPath = vm.zoomTo(Workspace::opened, Workspace::withOpened);
     *
     *  private final TreeConf<String, FsNode> shape =
     *          TreeConf.of(FsNode.class)
     *                  .nodesOf(Dir.class, it -> it.children(Dir::entries).text(Dir::name))
     *                  .nodesOf(Doc.class, it -> it.text(Doc::name));
     *
     *  private final Val<String> breadcrumb =
     *          Viewable.of(String.class, fileSystem, selectedPath,
     *                  (root, path) -> shape.nodesAlong(root, path)
     *                                       .stream().map(FsNode::name)
     *                                       .collect(Collectors.joining("  >  ")));
     *
     *  UI.panel("fill, wrap 1")
     *  .add("growx", UI.label(breadcrumb))   // Workspace  >  src  >  App.java
     *  .add("grow",
     *      UI.scrollPane().add(
     *          UI.tree(fileSystem, shape).withSelection(selectedPath)
     *      )
     *  );
     *  }</pre>
     *  The label reads correctly after a rename anywhere along that trail, because the trail
     *  is resolved from the ids the selection holds every time either property changes,
     *  rather than being a string somebody remembered to rebuild.
     *  <p>
     *  Use {@link #nodesAlong(Tuple, Tuple)} instead where the tree was bound with
     *  {@link UI#trees(sprouts.Var, swingtree.api.Configurator)}.
     *  <p>
     *  The result is empty when the path names nothing, so a partial trail is never
     *  returned: either the whole path resolves or none of it does.
     *
     * @param root The root value of the tree, which is what the path is relative to,
     *             or {@code null} where the property holding it is empty.
     * @param path The ids leading from the root down to the node, the root's own id first.
     * @return Every node from the root down to the named one, or an empty tuple.
     */
    public Tuple<N> nodesAlong( @Nullable N root, Tuple<I> path ) {
        Objects.requireNonNull(path, "path");
        if ( root == null )
            return Tuple.of(_nodeType);
        return nodesAlong(Tuple.of(_nodeType, Collections.singletonList(root)), path);
    }

    /**
     *  Resolves a selection path of a <b>forest</b> to every node along it, the top level node
     *  first and the named node last. It is {@link #nodesAlong(Object, Tuple)} in every respect
     *  but where the trail starts: a forest has no root, so its trails begin one level lower
     *  and the bar has one segment fewer.
     *  <pre>{@code
     *  private final Var<Tuple<FsNode>> projects     = vm.zoomTo(Workspace::open,   Workspace::withOpen);
     *  private final Var<Tuple<String>> selectedPath = vm.zoomTo(Workspace::opened, Workspace::withOpened);
     *
     *  private final TreeConf<String, FsNode> shape =
     *          TreeConf.of(FsNode.class)
     *                  .nodesOf(Dir.class, it -> it.children(Dir::entries).text(Dir::name))
     *                  .nodesOf(Doc.class, it -> it.text(Doc::name));
     *
     *  private final Val<String> breadcrumb =
     *          Viewable.of(String.class, projects, selectedPath,
     *                  (roots, path) -> shape.nodesAlong(roots, path)
     *                                        .stream().map(FsNode::name)
     *                                        .collect(Collectors.joining("  >  ")));
     *
     *  UI.panel("fill, wrap 1")
     *  .add("growx", UI.label(breadcrumb))   // myapp  >  src  >  App.java
     *  .add("grow",
     *      UI.scrollPane().add(
     *          UI.trees(projects, shape).withSelection(selectedPath)
     *      )
     *  );
     *  }</pre>
     *  Compare that comment with the one on {@link #nodesAlong(Object, Tuple)}: the workspace
     *  itself is not in the trail, because in a forest it is not a node at all. Which is the
     *  whole point of binding one — there is no invented container to explain away, either on
     *  screen or inside the paths the application stores.
     *
     * @param roots The top level nodes of the forest, which is what the path is relative to.
     * @param path The ids leading from a top level node down to the node, its own id first.
     * @return Every node from the top level down to the named one, or an empty tuple.
     */
    public Tuple<N> nodesAlong( Tuple<N> roots, Tuple<I> path ) {
        Objects.requireNonNull(roots, "roots");
        Objects.requireNonNull(path, "path");
        Tuple<N> empty = Tuple.of(_nodeType);
        if ( path.isEmpty() )
            return empty;
        int found = _indexOfId(roots, path.get(0));
        if ( found < 0 )
            return empty;
        List<N> along = new ArrayList<>(path.size());
        Object current = roots.get(found);
        along.add(_nodeType.cast(current));
        for ( int level = 1; level < path.size(); level++ ) {
            TreeNodeConf<N, ?> rule = ruleFor(current);
            if ( rule == null || !rule.hasChildrenRule() )
                return empty;
            Tuple<Object> children;
            try {
                children = rule.childrenOf(current);
            } catch (Exception e) {
                log.debug(SwingTree.get().logMarker(), "Failed to walk a tree path.", e);
                return empty;
            }
            found = _indexOfId(children, path.get(level));
            if ( found < 0 )
                return empty;
            current = children.get(found);
            along.add(_nodeType.cast(current));
        }
        return Tuple.of(_nodeType, along);
    }

    private int _indexOfId( Tuple<?> nodes, I id ) {
        for ( int i = 0; i < nodes.size(); i++ )
            if ( Objects.equals(idOf(nodes.get(i)), id) )
                return i;
        return -1;
    }

    Class<N> nodeType() {
        return _nodeType;
    }

    /** Null where no type was declared, in which case {@code PropertyTreeModel} derives one. */
    @Nullable Class<I> idType() {
        return _idType;
    }

    boolean leafWhenEmpty() {
        return _leafWhenEmpty;
    }

    boolean hasRules() {
        return !_rules.isEmpty();
    }

    /** Decides whether the tree gets an in place rename editor at all. */
    boolean hasRenamableRule() {
        for ( TreeNodeConf<N, ?> rule : _rules.values() )
            if ( rule.isRenamable() )
                return true;
        return false;
    }

    /**
     *  A declared id getter wins over {@link HasId}, and a node offering neither falls back
     *  to itself, which still works for a static tree of distinct siblings.
     */
    Object idOf( @Nullable Object node ) {
        if ( node == null )
            return NULL_ID;
        Function<N, I> getter = _idGetter;
        if ( getter != null ) {
            try {
                Object id = getter.apply(_nodeType.cast(node));
                if ( id != null )
                    return id;
            } catch (Exception e) {
                log.debug(SwingTree.get().logMarker(), "Failed to read the id of tree node '{}'.", node, e);
            }
        }
        if ( node instanceof HasId ) {
            Object id = ((HasId<?>) node).id();
            if ( id != null )
                return id;
        }
        return node;
    }

    /**
     *  Among all rules whose type is assignable from the node's type, the one whose type no
     *  other match is assignable from — so a concrete record beats the sealed interface
     *  above it. Where two matches are unrelated, the one declared first wins.
     */
    private @Nullable TreeNodeConf<N, ?> _findRule( Class<?> type ) {
        @Nullable TreeNodeConf<N, ?> best = null;
        for ( Pair<Class<?>, TreeNodeConf<N, ?>> entry : _rules.entrySet() ) {
            if ( !entry.first().isAssignableFrom(type) )
                continue;
            if ( best == null || best.type().isAssignableFrom(entry.first()) )
                best = entry.second();
        }
        return best;
    }

    private static final Object NO_RULE = new Object();
    static final Object NULL_ID = new Object() {
        @Override public String toString() { return "null"; }
    };

    @SuppressWarnings("unchecked")
    @Nullable TreeNodeConf<N, ?> ruleFor( @Nullable Object node ) {
        if ( node == null )
            return null;
        Class<?> type = node.getClass();
        Object cached = _ruleMemo.get(type);
        if ( cached == null ) {
            @Nullable TreeNodeConf<N, ?> found = _findRule(type);
            if ( found == null )
                log.warn(SwingTree.get().logMarker(),
                        "No tree node rule declared for type '{}'. It will be rendered as a leaf " +
                        "labelled by its 'toString()'. Declare one through 'nodesOf({}.class, ..)'.",
                        type.getName(), type.getSimpleName());
            cached = ( found == null ? NO_RULE : found );
            _ruleMemo.put(type, cached);
        }
        return cached == NO_RULE ? null : (TreeNodeConf<N, ?>) cached;
    }

    /**
     *  Tells whether a complaint about the given node type has still to be made,
     *  and remembers that it now has been.
     *
     * @param type The node type the complaint is about.
     * @return True the first time this configuration is asked about that type.
     */
    boolean shouldWarnAbout( Class<?> type ) {
        return _warnedAbout.add(type);
    }

    @Override
    public String toString() {
        StringBuilder types = new StringBuilder();
        for ( Class<?> type : _rules.keySet() ) {
            if ( types.length() > 0 )
                types.append(", ");
            types.append(type.getSimpleName());
        }
        return this.getClass().getSimpleName() + "[" +
                    "nodeType="      + _nodeType.getSimpleName() + ", " +
                    "rules=["        + types + "], " +
                    "idOf="          + ( _idGetter == null ? "byHasId" : "declared" ) + ", " +
                    "leafWhenEmpty=" + _leafWhenEmpty +
                "]";
    }
}