BaseNodeDeserializer.java

package tools.jackson.databind.deser.jackson;

import java.math.BigDecimal;
import java.util.Arrays;

import tools.jackson.core.*;
import tools.jackson.databind.*;
import tools.jackson.databind.cfg.DatatypeFeatures;
import tools.jackson.databind.cfg.JsonNodeFeature;
import tools.jackson.databind.deser.std.StdDeserializer;
import tools.jackson.databind.jsontype.TypeDeserializer;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ContainerNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import tools.jackson.databind.type.LogicalType;
import tools.jackson.databind.util.RawValue;

/**
 * Base class for all actual {@link JsonNode} deserializer implementations.
 * Uses iteration instead of recursion: this allows
 * handling of very deeply nested input structures.
 *<p>
 * This class should only be extended by internal Jackson deserializers.
 * It is not intended to be used by custom deserializers.
 */
public abstract class BaseNodeDeserializer<T extends JsonNode>
        extends StdDeserializer<T>
{
    protected final Boolean _supportsUpdates;

    protected final boolean _mergeArrays;
    protected final boolean _mergeObjects;

    public BaseNodeDeserializer(Class<T> vc, Boolean supportsUpdates) {
        super(vc);
        _supportsUpdates = supportsUpdates;
        _mergeArrays = true;
        _mergeObjects = true;
    }

    protected BaseNodeDeserializer(BaseNodeDeserializer<?> base,
            boolean mergeArrays, boolean mergeObjects)
    {
        super(base);
        _supportsUpdates = base._supportsUpdates;
        _mergeArrays = mergeArrays;
        _mergeObjects = mergeObjects;
    }

    @Override
    public Object deserializeWithType(JsonParser p, DeserializationContext ctxt,
            TypeDeserializer typeDeserializer)
        throws JacksonException
    {
        // Output can be as JSON Object, Array or scalar: no way to know a priori:
        return typeDeserializer.deserializeTypedFromAny(p, ctxt);
    }

    @Override // since 2.12
    public LogicalType logicalType() {
        return LogicalType.Untyped;
    }

    // 07-Nov-2014, tatu: When investigating [databind#604], realized that it makes
    //   sense to also mark this is cachable, since lookup not exactly free, and
    //   since it's not uncommon to "read anything"
    @Override
    public boolean isCachable() { return true; }

    @Override
    public Boolean supportsUpdate(DeserializationConfig config) {
        return _supportsUpdates;
    }

    @Override // @since 2.14
    public ValueDeserializer<?> createContextual(DeserializationContext ctxt,
            BeanProperty property)
    {
        // 13-Jun-2022, tatu: Should we care about property? For now, let's not yet.
        //   (merge info there accessible via "property.getMetadata().getMergeInfo()")
        final DeserializationConfig cfg = ctxt.getConfig();
        Boolean mergeArr = cfg.getDefaultMergeable(ArrayNode.class);
        Boolean mergeObj = cfg.getDefaultMergeable(ObjectNode.class);
        Boolean mergeNode = cfg.getDefaultMergeable(JsonNode.class);

        final boolean mergeArrays = _shouldMerge(mergeArr, mergeNode);
        final boolean mergeObjects = _shouldMerge(mergeObj, mergeNode);

        if ((mergeArrays != _mergeArrays)
                || (mergeObjects != _mergeObjects)) {
            return _createWithMerge(mergeArrays, mergeObjects);
        }

        return this;
    }

    private static boolean _shouldMerge(Boolean specificMerge, Boolean generalMerge) {
        if (specificMerge != null) {
            return specificMerge.booleanValue();
        }
        if (generalMerge != null) {
            return generalMerge.booleanValue();
        }
        return true;
    }

    // @since 2.14
    protected abstract BaseNodeDeserializer<?> _createWithMerge(boolean mergeArrays,
                                                                boolean mergeObjects);

    /*
    /**********************************************************************
    /* Duplicate handling
    /**********************************************************************
     */

    /**
     * Method called when there is a duplicate value for an Object property.
     * By default we don't care, and the last value is used.
     * Can be overridden to provide alternate handling, such as throwing
     * an exception, or choosing different strategy for combining values
     * or choosing which one to keep.
     *
     * @param propName Name of the property for which duplicate value was found
     * @param objectNode Object node that contains values
     * @param oldValue Value that existed for the object node before newValue
     *   was added
     * @param newValue Newly added value just added to the object node
     */
    protected void _handleDuplicateProperty(JsonParser p, DeserializationContext ctxt,
                                            JsonNodeFactory nodeFactory,
                                            String propName, ObjectNode objectNode,
                                            JsonNode oldValue, JsonNode newValue)
            throws JacksonException
    {
        // [databind#237]: Report an error if asked to do so:
        if (ctxt.isEnabled(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)) {
            // 11-Sep-2019, tatu: Cannot pass "property name" because we may be
            //    missing enclosing JSON content context...
// ctxt.reportPropertyInputMismatch(JsonNode.class, propName,
            ctxt.reportInputMismatch(JsonNode.class,
                    "Duplicate property \"%s\" for `ObjectNode`: not allowed when `DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY` enabled",
                    propName);
        }
        // [databind#2732]: Special case for XML; automatically coerce into `ArrayNode`
        if (ctxt.isEnabled(StreamReadCapability.DUPLICATE_PROPERTIES)) {
            // Note that ideally we wouldn't have to shuffle things but... Map.putIfAbsent()
            // only added in JDK 8, to efficiently check for add. So...
            if (oldValue.isArray()) { // already was array, to append
                ((ArrayNode) oldValue).add(newValue);
                objectNode.replace(propName, oldValue);
            } else { // was not array, convert
                ArrayNode arr = nodeFactory.arrayNode();
                arr.add(oldValue);
                arr.add(newValue);
                objectNode.replace(propName, arr);
            }
        }
    }

    /*
    /**********************************************************************
    /* Helper methods, deserialization
    /**********************************************************************
     */

    /**
     * Alternate deserialization method used when parser already points to first
     * PROPERTY_NAME and not START_OBJECT.
     */
    protected final ObjectNode _deserializeObjectAtName(JsonParser p, DeserializationContext ctxt,
                                                        final JsonNodeFactory nodeFactory, final ContainerStack stack)
            throws JacksonException
    {
        final ObjectNode node = nodeFactory.objectNode();
        String key = p.currentName();
        for (; key != null; key = p.nextName()) {
            JsonNode value;
            JsonToken t = p.nextToken();
            if (t == null) { // can this ever occur?
                t = JsonToken.NOT_AVAILABLE; // can this ever occur?
            }
            switch (t.id()) {
                case JsonTokenId.ID_START_OBJECT:
                    value = _deserializeContainerNoRecursion(p, ctxt, nodeFactory,
                            stack, nodeFactory.objectNode());
                    break;
                case JsonTokenId.ID_START_ARRAY:
                    value = _deserializeContainerNoRecursion(p, ctxt, nodeFactory,
                            stack, nodeFactory.arrayNode());
                    break;
                default:
                    value = _deserializeAnyScalar(p, ctxt);
            }
            JsonNode old = node.replace(key, value);
            if (old != null) {
                _handleDuplicateProperty(p, ctxt, nodeFactory,
                        key, node, old, value);
            }
        }
        return node;
    }

    /**
     * Alternate deserialization method that is to update existing {@link ObjectNode}
     * if possible.
     */
    protected final JsonNode updateObject(JsonParser p, DeserializationContext ctxt,
                                          final ObjectNode node, final ContainerStack stack)
            throws JacksonException
    {
        String key;
        if (p.isExpectedStartObjectToken()) {
            key = p.nextName();
        } else {
            if (!p.hasToken(JsonToken.PROPERTY_NAME)) {
                return deserialize(p, ctxt);
            }
            key = p.currentName();
        }
        final JsonNodeFactory nodeFactory = ctxt.getNodeFactory();
        for (; key != null; key = p.nextName()) {
            // If not, fall through to regular handling
            JsonToken t = p.nextToken();

            // First: see if we can merge things:
            JsonNode old = node.get(key);
            if (old != null) {
                if (old instanceof ObjectNode) {
                    // [databind#3056]: merging only if had Object and
                    // getting an Object
                    if ((t == JsonToken.START_OBJECT) && _mergeObjects) {
                        JsonNode newValue = updateObject(p, ctxt, (ObjectNode) old, stack);
                        if (newValue != old) {
                            node.set(key, newValue);
                        }
                        continue;
                    }
                } else if (old instanceof ArrayNode) {
                    // [databind#3056]: related to Object handling, ensure
                    // Array values also match for mergeability
                    if ((t == JsonToken.START_ARRAY) && _mergeArrays) {
                        // 28-Mar-2021, tatu: We'll only append entries so not very different
                        //    from "regular" deserializeArray...
                        _deserializeContainerNoRecursion(p, ctxt, nodeFactory,
                                stack, (ArrayNode) old);
                        continue;
                    }
                }
            }
            if (t == null) { // can this ever occur?
                t = JsonToken.NOT_AVAILABLE;
            }
            JsonNode value;
            switch (t.id()) {
                case JsonTokenId.ID_START_OBJECT:
                    value = _deserializeContainerNoRecursion(p, ctxt, nodeFactory,
                            stack, nodeFactory.objectNode());
                    break;
                case JsonTokenId.ID_START_ARRAY:
                    value = _deserializeContainerNoRecursion(p, ctxt, nodeFactory,
                            stack, nodeFactory.arrayNode());
                    break;
                case JsonTokenId.ID_STRING:
                    value = nodeFactory.stringNode(p.getString());
                    break;
                case JsonTokenId.ID_NUMBER_INT:
                    value = _fromInt(p, ctxt, nodeFactory);
                    break;
                case JsonTokenId.ID_TRUE:
                    value = nodeFactory.booleanNode(true);
                    break;
                case JsonTokenId.ID_FALSE:
                    value = nodeFactory.booleanNode(false);
                    break;
                case JsonTokenId.ID_NULL:
                    // 20-Mar-2022, tatu: [databind#3421] Allow skipping `null`s from JSON
                    if (!ctxt.isEnabled(JsonNodeFeature.READ_NULL_PROPERTIES)) {
                        continue;
                    }
                    value = nodeFactory.nullNode();
                    break;
                default:
                    value = _deserializeRareScalar(p, ctxt);
            }
            // 15-Feb-2021, tatu: I don't think this should have been called
            //   on update case (was until 2.12.2) and was simply result of
            //   copy-paste.
            /*
            if (old != null) {
                _handleDuplicateProperty(p, ctxt, nodeFactory,
                        key, node, old, value);
            }
            */
            node.set(key, value);
        }
        return node;
    }

    // Non-recursive alternative
    protected final ContainerNode<?> _deserializeContainerNoRecursion(JsonParser p, DeserializationContext ctxt,
                                                                      JsonNodeFactory nodeFactory, ContainerStack stack, final ContainerNode<?> root)
            throws JacksonException
    {
        ContainerNode<?> curr = root;
        final int intCoercionFeats = ctxt.getDeserializationFeatures() & F_MASK_INT_COERCIONS;

        outer_loop:
        do {
            if (curr instanceof ObjectNode) {
                ObjectNode currObject = (ObjectNode) curr;
                String propName = p.nextName();

                objectLoop:
                for (; propName != null; propName = p.nextName()) {
                    JsonNode value;
                    JsonToken t = p.nextToken();
                    if (t == null) { // unexpected end-of-input (or bad buffering?)
                        t = JsonToken.NOT_AVAILABLE; // to trigger an exception
                    }
                    switch (t.id()) {
                        case JsonTokenId.ID_START_OBJECT:
                        {
                            ObjectNode newOb = nodeFactory.objectNode();
                            JsonNode old = currObject.replace(propName, newOb);
                            if (old != null) {
                                _handleDuplicateProperty(p, ctxt, nodeFactory,
                                        propName, currObject, old, newOb);
                            }
                            stack.push(curr);
                            curr = currObject = newOb;
                            // We can actually take a short-cut with nested Objects...
                            continue objectLoop;
                        }
                        case JsonTokenId.ID_START_ARRAY:
                        {
                            ArrayNode newOb = nodeFactory.arrayNode();
                            JsonNode old = currObject.replace(propName, newOb);
                            if (old != null) {
                                _handleDuplicateProperty(p, ctxt, nodeFactory,
                                        propName, currObject, old, newOb);
                            }
                            stack.push(curr);
                            curr = newOb;
                        }
                        continue outer_loop;
                        case JsonTokenId.ID_STRING:
                            value = nodeFactory.stringNode(p.getString());
                            break;
                        case JsonTokenId.ID_NUMBER_INT:
                            value = _fromInt(p, intCoercionFeats, nodeFactory);
                            break;
                        case JsonTokenId.ID_NUMBER_FLOAT:
                            value = _fromFloat(p, ctxt, nodeFactory);
                            break;
                        case JsonTokenId.ID_TRUE:
                            value = nodeFactory.booleanNode(true);
                            break;
                        case JsonTokenId.ID_FALSE:
                            value = nodeFactory.booleanNode(false);
                            break;
                        case JsonTokenId.ID_NULL:
                            // 20-Mar-2022, tatu: [databind#3421] Allow skipping `null`s from JSON
                            if (!ctxt.isEnabled(JsonNodeFeature.READ_NULL_PROPERTIES)) {
                                continue;
                            }
                            value = nodeFactory.nullNode();
                            break;
                        default:
                            value = _deserializeRareScalar(p, ctxt);
                    }
                    JsonNode old = currObject.replace(propName, value);
                    if (old != null) {
                        _handleDuplicateProperty(p, ctxt, nodeFactory,
                                propName, currObject, old, value);
                    }
                }
                // reached not-property-name, should be END_OBJECT (verify?)
            } else {
                // Otherwise we must have an array
                final ArrayNode currArray = (ArrayNode) curr;

                arrayLoop:
                while (true) {
                    JsonToken t = p.nextToken();
                    if (t == null) { // unexpected end-of-input (or bad buffering?)
                        t = JsonToken.NOT_AVAILABLE; // to trigger an exception
                    }
                    switch (t.id()) {
                        case JsonTokenId.ID_START_OBJECT:
                            stack.push(curr);
                            curr = nodeFactory.objectNode();
                            currArray.add(curr);
                            continue outer_loop;
                        case JsonTokenId.ID_START_ARRAY:
                            stack.push(curr);
                            curr = nodeFactory.arrayNode();
                            currArray.add(curr);
                            continue outer_loop;
                        case JsonTokenId.ID_END_ARRAY:
                            break arrayLoop;
                        case JsonTokenId.ID_STRING:
                            currArray.add(nodeFactory.stringNode(p.getString()));
                            continue arrayLoop;
                        case JsonTokenId.ID_NUMBER_INT:
                            currArray.add(_fromInt(p, intCoercionFeats, nodeFactory));
                            continue arrayLoop;
                        case JsonTokenId.ID_NUMBER_FLOAT:
                            currArray.add(_fromFloat(p, ctxt, nodeFactory));
                            continue arrayLoop;
                        case JsonTokenId.ID_TRUE:
                            currArray.add(nodeFactory.booleanNode(true));
                            continue arrayLoop;
                        case JsonTokenId.ID_FALSE:
                            currArray.add(nodeFactory.booleanNode(false));
                            continue arrayLoop;
                        case JsonTokenId.ID_NULL:
                            currArray.add(nodeFactory.nullNode());
                            continue arrayLoop;
                        default:
                            currArray.add(_deserializeRareScalar(p, ctxt));
                            continue arrayLoop;
                    }
                }
                // Reached end of array (or input), so...
            }

            // Either way, Object or Array ended, return up nesting level:
            curr = stack.popOrNull();
        } while (curr != null);
        return root;
    }

    // Was called "deserializeAny()" in 2.12 and prior
    protected final JsonNode _deserializeAnyScalar(JsonParser p, DeserializationContext ctxt)
            throws JacksonException
    {
        final JsonNodeFactory nodeF = ctxt.getNodeFactory();
        switch (p.currentTokenId()) {
            case JsonTokenId.ID_END_OBJECT:
                return nodeF.objectNode();
            case JsonTokenId.ID_STRING:
                return nodeF.stringNode(p.getString());
            case JsonTokenId.ID_NUMBER_INT:
                return _fromInt(p, ctxt, nodeF);
            case JsonTokenId.ID_NUMBER_FLOAT:
                return _fromFloat(p, ctxt, nodeF);
            case JsonTokenId.ID_TRUE:
                return nodeF.booleanNode(true);
            case JsonTokenId.ID_FALSE:
                return nodeF.booleanNode(false);
            case JsonTokenId.ID_NULL:
                return nodeF.nullNode();
            case JsonTokenId.ID_EMBEDDED_OBJECT:
                return _fromEmbedded(p, ctxt);

            // Caller should check for anything else
            default:
        }
        return (JsonNode) ctxt.handleUnexpectedToken(handledType(), p);
    }

    protected final JsonNode _deserializeRareScalar(JsonParser p, DeserializationContext ctxt)
            throws JacksonException
    {
        // 28-Mar-2021, tatu: Only things that caller does not check
        switch (p.currentTokenId()) {
            case JsonTokenId.ID_END_OBJECT: // for empty JSON Objects we may point to this?
                return ctxt.getNodeFactory().objectNode();
            case JsonTokenId.ID_NUMBER_FLOAT:
                return _fromFloat(p, ctxt, ctxt.getNodeFactory());
            case JsonTokenId.ID_EMBEDDED_OBJECT:
                return _fromEmbedded(p, ctxt);

            // Caller should check for anything else
            default:
        }
        return (JsonNode) ctxt.handleUnexpectedToken(getValueType(ctxt), p);
    }

    protected final JsonNode _fromInt(JsonParser p, int coercionFeatures,
                                      JsonNodeFactory nodeFactory)
            throws JacksonException
    {
        if (coercionFeatures != 0) {
            if (DeserializationFeature.USE_BIG_INTEGER_FOR_INTS.enabledIn(coercionFeatures)) {
                return nodeFactory.numberNode(p.getBigIntegerValue());
            }
            return nodeFactory.numberNode(p.getLongValue());
        }
        final JsonParser.NumberType nt = p.getNumberType();
        if (nt == JsonParser.NumberType.INT) {
            return nodeFactory.numberNode(p.getIntValue());
        }
        if (nt == JsonParser.NumberType.LONG) {
            return nodeFactory.numberNode(p.getLongValue());
        }
        return nodeFactory.numberNode(p.getBigIntegerValue());
    }

    protected final JsonNode _fromInt(JsonParser p, DeserializationContext ctxt,
                                      JsonNodeFactory nodeFactory)
            throws JacksonException
    {
        JsonParser.NumberType nt;
        int feats = ctxt.getDeserializationFeatures();
        if ((feats & F_MASK_INT_COERCIONS) != 0) {
            if (DeserializationFeature.USE_BIG_INTEGER_FOR_INTS.enabledIn(feats)) {
                nt = JsonParser.NumberType.BIG_INTEGER;
            } else if (DeserializationFeature.USE_LONG_FOR_INTS.enabledIn(feats)) {
                nt = JsonParser.NumberType.LONG;
            } else {
                nt = p.getNumberType();
            }
        } else {
            nt = p.getNumberType();
        }
        if (nt == JsonParser.NumberType.INT) {
            return nodeFactory.numberNode(p.getIntValue());
        }
        if (nt == JsonParser.NumberType.LONG) {
            return nodeFactory.numberNode(p.getLongValue());
        }
        return nodeFactory.numberNode(p.getBigIntegerValue());
    }

    protected final JsonNode _fromFloat(JsonParser p, DeserializationContext ctxt,
            final JsonNodeFactory nodeFactory)
        throws JacksonException
    {
        // 13-Jan-2024, tatu: With 2.17 we have `JsonParser.getNumberTypeFP()` which
        //   will return concrete FP type if format has one (JSON doesn't; most binary
        //   formats do).
        //   But with `DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS` we have mechanism
        //   that may override optimal physical representation. So heuristics to use are:
        //
        //   1. If physical type is `BigDecimal`, use `BigDecimal`
        //   2. If `DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS` enabled, use `BigDecimal`
        //      UNLESS we have "Not-a-Number" (in which case  will coerce to `Double` if allowed
        //   3. Otherwise if physical type `Float`, use (32-bit) `Float`
        //   4. Otherwise use `Double`

        JsonParser.NumberTypeFP nt = p.getNumberTypeFP();
        if (nt == JsonParser.NumberTypeFP.BIG_DECIMAL) {
            BigDecimal nr = p.getDecimalValue();
            if (ctxt.isEnabled(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES)) {
                nr = _normalize(nr);
            }
            return nodeFactory.numberNode(nr);
        }
        // [databind#4801] Add JsonNodeFeature.USE_BIG_DECIMAL_FOR_FLOATS
        DatatypeFeatures dtf = ctxt.getDatatypeFeatures();
        Boolean dtfState = dtf.getExplicitState(JsonNodeFeature.USE_BIG_DECIMAL_FOR_FLOATS);
        boolean useBigDecimal = (dtfState == null) // not explicitly set
                ? ctxt.isEnabled(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
                    : dtfState.booleanValue();

        if (useBigDecimal) {
            // [databind#4194] Add an option to fail coercing NaN to BigDecimal
            // Currently, Jackson 2.x allows such coercion, but Jackson 3.x will not
            if (p.isNaN()) {
                if (ctxt.isEnabled(JsonNodeFeature.FAIL_ON_NAN_TO_BIG_DECIMAL_COERCION)) {
                    return (JsonNode) ctxt.handleWeirdNumberValue(handledType(), p.getDoubleValue(),
                        "Cannot convert NaN into BigDecimal");
                }
                return nodeFactory.numberNode(p.getDoubleValue());
            }
            BigDecimal nr = p.getDecimalValue();
            if (ctxt.isEnabled(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES)) {
                nr = _normalize(nr);
            }
            return nodeFactory.numberNode(nr);
        }
        if (nt == JsonParser.NumberTypeFP.FLOAT32) {
            return nodeFactory.numberNode(p.getFloatValue());
        }
        return nodeFactory.numberNode(p.getDoubleValue());
    }

    protected BigDecimal _normalize(BigDecimal nr) {
        // 24-Mar-2021, tatu: [dataformats-binary#264] barfs on a specific value...
        //   Must skip normalization in that particular case. Alas, haven't found
        //   another way to check it instead of getting "Overflow", catching
        try {
            nr = nr.stripTrailingZeros();
        } catch (ArithmeticException e) {
            // If we can't, we can't...
            ;
        }
        return nr;
    }

    protected final JsonNode _fromEmbedded(JsonParser p, DeserializationContext ctxt)
            throws JacksonException
    {
        final JsonNodeFactory nodeF = ctxt.getNodeFactory();
        final Object ob = p.getEmbeddedObject();

        if (ob == null) { // should this occur?
            return nodeF.nullNode();
        }
        Class<?> type = ob.getClass();
        if (type == byte[].class) { // most common special case
            return nodeF.binaryNode((byte[]) ob);
        }
        // [databind#743]: Don't forget RawValue
        if (ob instanceof RawValue) {
            return nodeF.rawValueNode((RawValue) ob);
        }
        if (ob instanceof JsonNode) {
            // [databind#433]: but could also be a JsonNode hiding in there!
            return (JsonNode) ob;
        }
        // any other special handling needed?
        return nodeF.pojoNode(ob);
    }

    /*
    /**********************************************************************
    /* Helper classes
    /**********************************************************************
     */

    /**
     * Optimized variant similar in functionality to (a subset of)
     * {@link java.util.ArrayDeque}; used to hold enclosing Array/Object
     * nodes during recursion-as-iteration.
     */
    @SuppressWarnings("rawtypes")
    final static class ContainerStack
    {
        private ContainerNode[] _stack;
        private int _top, _end;

        public ContainerStack() { }

        // Not used yet but useful for limits (fail at [some high depth])
        public int size() { return _top; }

        public void push(ContainerNode node)
        {
            if (_top < _end) {
                _stack[_top++] = node; // lgtm [java/dereferenced-value-may-be-null]
                return;
            }
            if (_stack == null) {
                _end = 10;
                _stack = new ContainerNode[_end];
            } else {
                // grow by 50%, for most part
                _end += Math.min(4000, Math.max(20, _end>>1));
                _stack = Arrays.copyOf(_stack, _end);
            }
            _stack[_top++] = node;
        }

        public ContainerNode popOrNull() {
            if (_top == 0) {
                return null;
            }
            // note: could clean up stack but due to usage pattern, should not make
            // any difference -- all nodes joined during and after construction and
            // after construction the whole stack is discarded
            return _stack[--_top];
        }
    }
}