From 06eae4841e6b0d4ef3ab3561e9943afc48d5b4b4 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Sun, 7 Jun 2026 22:35:04 +0200 Subject: [PATCH] feat: add structural mutation helpers (insert/move/replace/sort children) (#34) (#41) --- CHANGELOG.md | 4 + api/tree-structure.api | 6 + .../datastructure/tree/TreeNode.kt | 111 ++++++++++++- .../TreeNodeMutationTest.kt | 151 ++++++++++++++++++ 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeMutationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 588ba01..009d539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Added +- Structural mutation helpers on `TreeNode`: `insertChild`, `removeChildAt`, `replaceChild`, + `moveChild`, `addChildren`, and `sortChildren`. + ### Changed - Rewrote the README for clarity: one consistent example tree, task-oriented sections (building, traversal, navigation, functional, utilities, mutating), per-module usage, and a diff --git a/api/tree-structure.api b/api/tree-structure.api index 2bd2935..26ecc33 100644 --- a/api/tree-structure.api +++ b/api/tree-structure.api @@ -10,6 +10,7 @@ public class com/github/adriankuta/datastructure/tree/TreeNode : com/github/adri public fun (Ljava/lang/Object;Lcom/github/adriankuta/datastructure/tree/iterators/TreeNodeIterators;)V public synthetic fun (Ljava/lang/Object;Lcom/github/adriankuta/datastructure/tree/iterators/TreeNodeIterators;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addChild (Lcom/github/adriankuta/datastructure/tree/TreeNode;)V + public final fun addChildren ([Lcom/github/adriankuta/datastructure/tree/TreeNode;)V public synthetic fun child (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Lcom/github/adriankuta/datastructure/tree/TreeNode; public final fun clear ()V public final fun depth ()I @@ -19,13 +20,18 @@ public class com/github/adriankuta/datastructure/tree/TreeNode : com/github/adri public final fun getTreeIterator ()Lcom/github/adriankuta/datastructure/tree/iterators/TreeNodeIterators; public final fun getValue ()Ljava/lang/Object; public final fun height ()I + public final fun insertChild (ILcom/github/adriankuta/datastructure/tree/TreeNode;)V public final fun isRoot ()Z public fun iterator ()Ljava/util/Iterator; public final fun iterator (Lcom/github/adriankuta/datastructure/tree/iterators/TreeNodeIterators;)Ljava/util/Iterator; + public final fun moveChild (Lcom/github/adriankuta/datastructure/tree/TreeNode;I)Z public final fun nodeCount ()I public final fun path (Lcom/github/adriankuta/datastructure/tree/TreeNode;)Ljava/util/List; public final fun prettyString ()Ljava/lang/String; public final fun removeChild (Lcom/github/adriankuta/datastructure/tree/TreeNode;)Z + public final fun removeChildAt (I)Lcom/github/adriankuta/datastructure/tree/TreeNode; + public final fun replaceChild (ILcom/github/adriankuta/datastructure/tree/TreeNode;)Lcom/github/adriankuta/datastructure/tree/TreeNode; + public final fun sortChildren (Ljava/util/Comparator;)V public fun toString ()Ljava/lang/String; } diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt index 16cb231..55bd464 100644 --- a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt +++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt @@ -61,6 +61,19 @@ public open class TreeNode(public val value: T, public val treeIterator: Tree * a cycle (i.e. [child] is this node or one of its ancestors). */ public fun addChild(child: TreeNode) { + validateAttachable(child) + child._parent = this + _children.add(child) + } + + /** + * Validates that [child] can be attached as a direct child of this node, throwing if it cannot. + * + * @param child the node about to be attached. + * @throws TreeNodeException if [child] already has a parent, or if attaching it here would create + * a cycle (i.e. [child] is this node or one of its ancestors). + */ + private fun validateAttachable(child: TreeNode) { if (child._parent != null) { throw TreeNodeException("$child already has a parent; call detach() before re-attaching it.") } @@ -78,8 +91,6 @@ public open class TreeNode(public val value: T, public val treeIterator: Tree ancestor = ancestor._parent } } - child._parent = this - _children.add(child) } /** @@ -118,6 +129,102 @@ public open class TreeNode(public val value: T, public val treeIterator: Tree return removed } + /** + * Inserts [child] as a direct child of this node at the given [index], shifting any existing + * children at and after [index] one position to the right. + * + * @param index the position at which to insert [child]; must be in `0..children.size`. + * @param child a node that is not already attached to a tree. To move a node that already has a + * parent, call [detach] on it first. + * @throws IndexOutOfBoundsException if [index] is out of range. + * @throws TreeNodeException if [child] already has a parent, or if attaching it here would create + * a cycle (i.e. [child] is this node or one of its ancestors). + */ + public fun insertChild(index: Int, child: TreeNode) { + validateAttachable(child) + child._parent = this + _children.add(index, child) + } + + /** + * Removes the direct child at the given [index], detaching it (its parent becomes `null`). + * + * @param index the position of the child to remove; must be in `0 until children.size`. + * @return the detached child that was at [index]. + * @throws IndexOutOfBoundsException if [index] is out of range. + */ + public fun removeChildAt(index: Int): TreeNode { + val removed = _children.removeAt(index) + removed._parent = null + return removed + } + + /** + * Replaces the direct child at the given [index] with [child], detaching the previous child + * (its parent becomes `null`). + * + * @param index the position of the child to replace; must be in `0 until children.size`. + * @param child a node that is not already attached to a tree. To move a node that already has a + * parent, call [detach] on it first. + * @return the previous child that was at [index], now detached. + * @throws IndexOutOfBoundsException if [index] is out of range. + * @throws TreeNodeException if [child] already has a parent, or if attaching it here would create + * a cycle (i.e. [child] is this node or one of its ancestors). + */ + public fun replaceChild(index: Int, child: TreeNode): TreeNode { + validateAttachable(child) + val old = _children[index] + old._parent = null + child._parent = this + _children[index] = child + return old + } + + /** + * Moves an existing direct [child] to a new position within this node's [children]. + * + * [toIndex] is coerced into the valid range, so out-of-range targets clamp to the first or last + * position. Because [child] is already a direct child, no re-parenting or cycle check is needed. + * + * @param child the node to reorder; must already be a direct child of this node. + * @param toIndex the target position for [child] after removal, coerced into `0..children.size`. + * @return `true` if [child] was a direct child and has been moved; `false` otherwise. + */ + public fun moveChild(child: TreeNode, toIndex: Int): Boolean { + val from = _children.indexOf(child) + if (from < 0) return false + _children.removeAt(from) + _children.add(toIndex.coerceIn(0, _children.size), child) + return true + } + + /** + * Adds each of [children] as a direct child of this node, in order, validating each one the same + * way as [addChild]. + * + * Validation is performed per node as it is added, so if one node fails the children added before + * it remain attached (the same partial-application behaviour as calling [addChild] in a loop). + * + * @param children nodes that are not already attached to a tree. + * @throws TreeNodeException if any node already has a parent, or if attaching it here would create + * a cycle (i.e. it is this node or one of its ancestors). + */ + public fun addChildren(vararg children: TreeNode) { + for (child in children) { + addChild(child) + } + } + + /** + * Sorts this node's direct [children] in place according to the given [comparator]. Only the + * immediate children are reordered; their subtrees are left untouched. + * + * @param comparator the comparator used to order the children. + */ + public fun sortChildren(comparator: Comparator>) { + _children.sortWith(comparator) + } + /** * This function go through tree and counts children. Root element is not counted. * @return All child and nested child count. diff --git a/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeMutationTest.kt b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeMutationTest.kt new file mode 100644 index 0000000..6cb4269 --- /dev/null +++ b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeMutationTest.kt @@ -0,0 +1,151 @@ +package com.github.adriankuta.datastructure.tree + +import com.github.adriankuta.datastructure.tree.exceptions.TreeNodeException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TreeNodeMutationTest { + + @Test + fun insertChildAtStartMiddleAndEnd() { + val root = TreeNode("root") + val a = TreeNode("a") + val b = TreeNode("b") + val c = TreeNode("c") + val d = TreeNode("d") + + root.insertChild(0, b) // [b] + root.insertChild(0, a) // [a, b] + root.insertChild(2, d) // [a, b, d] (end) + root.insertChild(2, c) // [a, b, c, d] (middle) + + assertContentEquals(listOf(a, b, c, d), root.children) + // Each inserted node is re-parented to root. + assertSame(root, a.parent) + assertSame(root, b.parent) + assertSame(root, c.parent) + assertSame(root, d.parent) + } + + @Test + fun removeChildAtReturnsDetachedNodeAndClearsParent() { + val root = TreeNode("root") + val a = TreeNode("a") + val b = TreeNode("b") + val c = TreeNode("c") + root.addChildren(a, b, c) + + val removed = root.removeChildAt(1) + + assertSame(b, removed) + assertNull(removed.parent) + assertContentEquals(listOf(a, c), root.children) + } + + @Test + fun replaceChildSwapsAndDetachesTheOld() { + val root = TreeNode("root") + val a = TreeNode("a") + val b = TreeNode("b") + val replacement = TreeNode("replacement") + root.addChildren(a, b) + + val old = root.replaceChild(0, replacement) + + assertSame(a, old) + assertNull(old.parent) + assertSame(root, replacement.parent) + assertContentEquals(listOf(replacement, b), root.children) + } + + @Test + fun moveChildReordersChildren() { + val root = TreeNode("root") + val a = TreeNode("a") + val b = TreeNode("b") + val c = TreeNode("c") + root.addChildren(a, b, c) + + assertTrue(root.moveChild(a, 2)) + assertContentEquals(listOf(b, c, a), root.children) + // Parent pointer is unchanged after a move. + assertSame(root, a.parent) + } + + @Test + fun moveChildReturnsFalseForNonChild() { + val root = TreeNode("root") + val a = TreeNode("a") + root.addChild(a) + + val stranger = TreeNode("stranger") + assertFalse(root.moveChild(stranger, 0)) + assertContentEquals(listOf(a), root.children) + } + + @Test + fun addChildrenAppendsAllInOrder() { + val root = TreeNode("root") + val a = TreeNode("a") + val b = TreeNode("b") + val c = TreeNode("c") + + root.addChildren(a, b, c) + + assertContentEquals(listOf(a, b, c), root.children) + assertSame(root, a.parent) + assertSame(root, b.parent) + assertSame(root, c.parent) + } + + @Test + fun addChildrenRejectsNodeThatAlreadyHasAParent() { + val root = TreeNode("root") + val attached = TreeNode("attached") + TreeNode("other").addChild(attached) + + assertFailsWith { root.addChildren(attached) } + } + + @Test + fun insertChildRejectsNodeThatAlreadyHasAParent() { + val root = TreeNode("root") + val attached = TreeNode("attached") + TreeNode("other").addChild(attached) + + assertFailsWith { root.insertChild(0, attached) } + } + + @Test + fun replaceChildRejectsNodeThatAlreadyHasAParent() { + val root = TreeNode("root") + val existing = TreeNode("existing") + root.addChild(existing) + + val attached = TreeNode("attached") + TreeNode("other").addChild(attached) + + assertFailsWith { root.replaceChild(0, attached) } + // The original child is untouched after a failed replace. + assertContentEquals(listOf(existing), root.children) + assertSame(root, existing.parent) + } + + @Test + fun sortChildrenReordersByComparator() { + val root = TreeNode("root") + val c = TreeNode("c") + val a = TreeNode("a") + val b = TreeNode("b") + root.addChildren(c, a, b) + + root.sortChildren(compareBy { it.value }) + + assertContentEquals(listOf(a, b, c), root.children) + } +}