From f47fb091eca292a942e02e954d4a4d5b44c691a8 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Sun, 7 Jun 2026 22:40:41 +0200 Subject: [PATCH] feat: add tree-structure-immutable module (persistent ImmutableTreeNode) (#33) (#44) --- CHANGELOG.md | 3 + build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + .../api/tree-structure-immutable.api | 25 +++ tree-structure-immutable/build.gradle.kts | 101 ++++++++++++ .../tree/immutable/ImmutableTreeNode.kt | 147 ++++++++++++++++++ .../tree/immutable/ImmutableTreeNodeTest.kt | 135 ++++++++++++++++ 8 files changed, 415 insertions(+) create mode 100644 tree-structure-immutable/api/tree-structure-immutable.api create mode 100644 tree-structure-immutable/build.gradle.kts create mode 100644 tree-structure-immutable/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNode.kt create mode 100644 tree-structure-immutable/src/commonTest/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNodeTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 107a384..87b2e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ All notable changes to this project are documented here. The format is based on `TreeConnectors` (`Default` box-drawing or `Ascii`) and supply a per-node renderer that receives the value, its depth and whether it is its parent's last child. The all-defaults call is byte-identical to the existing no-arg `prettyString()`. +- New `tree-structure-immutable` module: a persistent `ImmutableTreeNode` with structural sharing + (`addChild`/`removeChild`/`mapValues` return new roots; pre/post/level-order traversals, + `nodeCount`, and `height`). ### Changed - Rewrote the README for clarity: one consistent example tree, task-oriented sections diff --git a/build.gradle.kts b/build.gradle.kts index 66ac2e4..db0806f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { dokka(project(":tree-structure-serialization")) dokka(project(":tree-structure-coroutines")) dokka(project(":tree-structure-compose")) + dokka(project(":tree-structure-immutable")) } dokka { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index edfd871..4c863bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ binaryCompatibilityValidator = "0.16.3" kover = "0.8.3" coroutines = "1.9.0" kotlinxSerialization = "1.7.3" +kotlinxCollectionsImmutable = "0.3.8" composeMultiplatform = "1.7.3" [plugins] @@ -22,3 +23,4 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 43aaf1b..2daffa2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,3 +3,4 @@ rootProject.name = "tree-structure" include(":tree-structure-serialization") include(":tree-structure-coroutines") include(":tree-structure-compose") +include(":tree-structure-immutable") diff --git a/tree-structure-immutable/api/tree-structure-immutable.api b/tree-structure-immutable/api/tree-structure-immutable.api new file mode 100644 index 0000000..d6ccac6 --- /dev/null +++ b/tree-structure-immutable/api/tree-structure-immutable.api @@ -0,0 +1,25 @@ +public final class com/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode { + public fun (Ljava/lang/Object;Lkotlinx/collections/immutable/PersistentList;)V + public synthetic fun (Ljava/lang/Object;Lkotlinx/collections/immutable/PersistentList;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addChild (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;)Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode; + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()Lkotlinx/collections/immutable/PersistentList; + public final fun copy (Ljava/lang/Object;Lkotlinx/collections/immutable/PersistentList;)Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode; + public static synthetic fun copy$default (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;Ljava/lang/Object;Lkotlinx/collections/immutable/PersistentList;ILjava/lang/Object;)Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode; + public fun equals (Ljava/lang/Object;)Z + public final fun getChildren ()Lkotlinx/collections/immutable/PersistentList; + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public final fun mapValues (Lkotlin/jvm/functions/Function1;)Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode; + public final fun removeChild (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;)Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode; + public fun toString ()Ljava/lang/String; +} + +public final class com/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNodeKt { + public static final fun height (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;)I + public static final fun levelOrder (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;)Ljava/util/List; + public static final fun nodeCount (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;)I + public static final fun postOrder (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;)Ljava/util/List; + public static final fun preOrder (Lcom/github/adriankuta/datastructure/tree/immutable/ImmutableTreeNode;)Ljava/util/List; +} + diff --git a/tree-structure-immutable/build.gradle.kts b/tree-structure-immutable/build.gradle.kts new file mode 100644 index 0000000..94b78d5 --- /dev/null +++ b/tree-structure-immutable/build.gradle.kts @@ -0,0 +1,101 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.dokka) + alias(libs.plugins.mavenPublish) + signing +} + +group = "com.github.adriankuta" +version = rootProject.version + +mavenPublishing { + publishToMavenCentral(automaticRelease = false) + signAllPublications() + + coordinates("com.github.adriankuta", "tree-structure-immutable", version.toString()) + + pom { + name.set("Tree Data Structure — immutable") + description.set("Immutable, persistent tree variant (ImmutableTreeNode with structural sharing) for the tree-structure library.") + url.set("https://github.com/AdrianKuta/Tree-Data-Structure") + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + distribution.set("repo") + } + } + developers { + developer { + id.set("AdrianKuta") + name.set("Adrian Kuta") + email.set("adrian.kuta93@gmail.com") + } + } + scm { + url.set("https://github.com/AdrianKuta/Tree-Data-Structure") + connection.set("scm:git:https://github.com/AdrianKuta/Tree-Data-Structure.git") + developerConnection.set("scm:git:ssh://git@github.com/AdrianKuta/Tree-Data-Structure.git") + } + } +} + +repositories { + mavenCentral() +} + +dokka { + dokkaSourceSets.configureEach { + sourceLink { + // Resolve this module's GitHub source path relative to the repo root. + localDirectory.set(projectDir.resolve("src")) + val module = projectDir.relativeTo(rootDir).invariantSeparatorsPath + val prefix = if (module.isEmpty()) "" else "$module/" + remoteUrl("https://github.com/AdrianKuta/Tree-Data-Structure/blob/master/${prefix}src") + remoteLineSuffix.set("#L") + } + } +} + +kotlin { + explicitApi() + jvmToolchain(21) + + jvm() + + js(IR) { + browser() + nodejs() + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + nodejs() + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + val hostOs = System.getProperty("os.name") + val isMingwX64 = hostOs.startsWith("Windows") + when { + hostOs == "Mac OS X" -> macosX64("native") + hostOs == "Linux" -> linuxX64("native") + isMingwX64 -> mingwX64("native") + else -> throw GradleException("Host OS is not supported in Kotlin/Native.") + } + + sourceSets { + commonMain.dependencies { + api(project(":")) + implementation(libs.kotlinx.collections.immutable) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} diff --git a/tree-structure-immutable/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNode.kt b/tree-structure-immutable/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNode.kt new file mode 100644 index 0000000..8eeb415 --- /dev/null +++ b/tree-structure-immutable/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNode.kt @@ -0,0 +1,147 @@ +package com.github.adriankuta.datastructure.tree.immutable + +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +/** + * A node in an immutable, persistent n-ary tree. Each node holds a [value] and an ordered + * [PersistentList] of [children]; nodes never carry a parent back-reference, so a subtree is a + * self-contained, acyclic value. + * + * Every mutating operation ([addChild], [removeChild], [mapValues]) returns a **new** root and + * leaves the receiver untouched. Subtrees that are not on the path of the change are reused as the + * same instances (structural sharing), so updates are cheap and old roots stay valid. + * + * Equality is value-based: two nodes are equal when their [value]s and [children] are equal + * (a `data class`), independent of identity. + * + * @param value the value stored in this node. + * @param children the ordered, persistent list of child subtrees. + */ +public data class ImmutableTreeNode( + public val value: T, + public val children: PersistentList> = persistentListOf(), +) { + + /** + * Returns a new node with [child] appended to this node's [children]. The receiver and every + * existing child subtree are reused unchanged (structural sharing). + * + * @param child the subtree to append. + * @return a new [ImmutableTreeNode] with [child] added; the receiver is not modified. + */ + public fun addChild(child: ImmutableTreeNode): ImmutableTreeNode = + copy(children = children.add(child)) + + /** + * Returns a new node with the first occurrence of [child] removed from this node's direct + * [children], compared by value-based equality. If [child] is not a direct child, a structurally + * equal new node is returned. The receiver is never modified. + * + * @param child the direct child subtree to remove. + * @return a new [ImmutableTreeNode] without [child]; the receiver is not modified. + */ + public fun removeChild(child: ImmutableTreeNode): ImmutableTreeNode = + copy(children = children.remove(child)) + + /** + * Returns a new tree of the same shape with every node's value transformed by [transform]. + * The receiver is not modified. + * + * @param transform maps each node's value of type [T] to a value of type [R]. + * @return a new [ImmutableTreeNode] of type [R] mirroring this tree's structure. + */ + public fun mapValues(transform: (T) -> R): ImmutableTreeNode = + ImmutableTreeNode(transform(value), children.map { it.mapValues(transform) }.toPersistentList()) +} + +/** + * Returns this subtree's nodes in pre-order (the receiver first, then each child subtree in order). + * Implemented iteratively, so it is safe on arbitrarily deep trees. + * + * @return the nodes of this subtree in pre-order, starting with the receiver. + */ +public fun ImmutableTreeNode.preOrder(): List> { + val result = mutableListOf>() + val stack = ArrayDeque>() + stack.addLast(this) + while (stack.isNotEmpty()) { + val node = stack.removeLast() + result.add(node) + node.children.asReversed().forEach { stack.addLast(it) } + } + return result +} + +/** + * Returns this subtree's nodes in post-order (each child subtree in order, then the receiver last). + * Implemented iteratively, so it is safe on arbitrarily deep trees. + * + * @return the nodes of this subtree in post-order, ending with the receiver. + */ +public fun ImmutableTreeNode.postOrder(): List> { + val result = ArrayDeque>() + val stack = ArrayDeque>() + stack.addLast(this) + while (stack.isNotEmpty()) { + val node = stack.removeLast() + result.addFirst(node) + node.children.forEach { stack.addLast(it) } + } + return result.toList() +} + +/** + * Returns this subtree's nodes in level-order (breadth-first: the receiver, then its children, then + * their children, and so on). Implemented iteratively, so it is safe on arbitrarily deep trees. + * + * @return the nodes of this subtree in breadth-first order, starting with the receiver. + */ +public fun ImmutableTreeNode.levelOrder(): List> { + val result = mutableListOf>() + val queue = ArrayDeque>() + queue.addLast(this) + while (queue.isNotEmpty()) { + val node = queue.removeFirst() + result.add(node) + node.children.forEach { queue.addLast(it) } + } + return result +} + +/** + * Counts all descendants of this node; the receiver itself is not counted (matching the core + * `TreeNode.nodeCount`). Implemented iteratively, so it is safe on arbitrarily deep trees. + * + * @return the number of descendant nodes (children and nested children) of this node. + */ +public fun ImmutableTreeNode.nodeCount(): Int { + var count = 0 + val stack = ArrayDeque>() + stack.addAll(children) + while (stack.isNotEmpty()) { + val node = stack.removeLast() + count++ + stack.addAll(node.children) + } + return count +} + +/** + * Returns the number of edges on the longest path between this node and a descendant leaf (0 for a + * leaf). Implemented iteratively, so it is safe on arbitrarily deep trees. + * + * @return the height of this subtree, measured in edges. + */ +public fun ImmutableTreeNode.height(): Int { + var maxDepth = 0 + val stack = ArrayDeque, Int>>() + stack.addLast(this to 0) + while (stack.isNotEmpty()) { + val (node, depthSoFar) = stack.removeLast() + if (depthSoFar > maxDepth) maxDepth = depthSoFar + node.children.forEach { stack.addLast(it to depthSoFar + 1) } + } + return maxDepth +} diff --git a/tree-structure-immutable/src/commonTest/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNodeTest.kt b/tree-structure-immutable/src/commonTest/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNodeTest.kt new file mode 100644 index 0000000..9dc071a --- /dev/null +++ b/tree-structure-immutable/src/commonTest/kotlin/com.github.adriankuta/datastructure/tree/immutable/ImmutableTreeNodeTest.kt @@ -0,0 +1,135 @@ +package com.github.adriankuta.datastructure.tree.immutable + +import kotlinx.collections.immutable.persistentListOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ImmutableTreeNodeTest { + + // World + // ├── North America + // │ └── USA + // └── Europe + // ├── Poland + // └── Germany + private val usa = ImmutableTreeNode("USA") + private val northAmerica = ImmutableTreeNode("North America", persistentListOf(usa)) + private val poland = ImmutableTreeNode("Poland") + private val germany = ImmutableTreeNode("Germany") + private val europe = ImmutableTreeNode("Europe", persistentListOf(poland, germany)) + private val world = ImmutableTreeNode("World", persistentListOf(northAmerica, europe)) + + @Test + fun addChildReturnsNewInstanceAndLeavesOriginalUnchanged() { + val asia = ImmutableTreeNode("Asia") + val updated = world.addChild(asia) + + assertEquals(3, updated.children.size) + assertEquals("Asia", updated.children[2].value) + + // Original is untouched. + assertEquals(2, world.children.size) + assertFalse(updated === world) + } + + @Test + fun removeChildReturnsNewInstanceAndLeavesOriginalUnchanged() { + val updated = world.removeChild(europe) + + assertEquals(1, updated.children.size) + assertEquals("North America", updated.children[0].value) + + // Original is untouched. + assertEquals(2, world.children.size) + assertFalse(updated === world) + } + + @Test + fun addChildSharesUnmodifiedSiblingSubtrees() { + val asia = ImmutableTreeNode("Asia") + val updated = world.addChild(asia) + + // The siblings that are not on the modified path are the SAME instances. + assertSame(northAmerica, updated.children[0]) + assertSame(europe, updated.children[1]) + } + + @Test + fun rebuildingOnlyOnePathSharesTheOtherSubtree() { + // Add a child under Europe; North America's subtree should be reused untouched. + val spain = ImmutableTreeNode("Spain") + val newEurope = europe.addChild(spain) + val updated = world.copy(children = world.children.set(1, newEurope)) + + assertSame(northAmerica, updated.children[0]) + assertFalse(updated.children[1] === europe) + assertSame(usa, updated.children[0].children[0]) + } + + @Test + fun mapValuesTransformsEveryValueAndKeepsShape() { + val lengths = world.mapValues { it.length } + + assertEquals("World".length, lengths.value) + assertEquals(2, lengths.children.size) + assertEquals("North America".length, lengths.children[0].value) + assertEquals("USA".length, lengths.children[0].children[0].value) + assertEquals("Germany".length, lengths.children[1].children[1].value) + } + + @Test + fun preOrderVisitsParentBeforeChildren() { + assertEquals( + listOf("World", "North America", "USA", "Europe", "Poland", "Germany"), + world.preOrder().map { it.value }, + ) + } + + @Test + fun postOrderVisitsChildrenBeforeParent() { + assertEquals( + listOf("USA", "North America", "Poland", "Germany", "Europe", "World"), + world.postOrder().map { it.value }, + ) + } + + @Test + fun levelOrderVisitsBreadthFirst() { + assertEquals( + listOf("World", "North America", "Europe", "USA", "Poland", "Germany"), + world.levelOrder().map { it.value }, + ) + } + + @Test + fun nodeCountExcludesReceiver() { + assertEquals(5, world.nodeCount()) + assertEquals(1, northAmerica.nodeCount()) + assertEquals(0, usa.nodeCount()) + } + + @Test + fun heightCountsEdgesOnLongestPath() { + assertEquals(2, world.height()) + assertEquals(1, europe.height()) + assertEquals(0, usa.height()) + } + + @Test + fun equalityIsValueBased() { + val sameWorld = ImmutableTreeNode( + "World", + persistentListOf( + ImmutableTreeNode("North America", persistentListOf(ImmutableTreeNode("USA"))), + ImmutableTreeNode("Europe", persistentListOf(ImmutableTreeNode("Poland"), ImmutableTreeNode("Germany"))), + ), + ) + + assertEquals(world, sameWorld) + assertTrue(world == sameWorld) + assertFalse(world === sameWorld) + } +}