From b5d75d4225c144ae745abf97080d6b81b813ac50 Mon Sep 17 00:00:00 2001 From: Robert Stoll Date: Sun, 3 Mar 2024 22:49:55 +0100 Subject: [PATCH] dynamic breadth- and depth-first traversal --- build.gradle.kts | 5 + gradle/scripts/detekt.yml | 4 +- .../ch/tutteli/kbox/dynamicTraversal.kt | 132 ++++++++++++ .../tutteli/kbox/impl/DynamicTreeTraversal.kt | 117 +++++++++++ .../tutteli/kbox/DynamicTreeTraversalSpec.kt | 192 ++++++++++++++++++ 5 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 src/commonMain/kotlin/ch/tutteli/kbox/dynamicTraversal.kt create mode 100644 src/commonMain/kotlin/ch/tutteli/kbox/impl/DynamicTreeTraversal.kt create mode 100644 src/commonTest/kotlin/ch/tutteli/kbox/DynamicTreeTraversalSpec.kt diff --git a/build.gradle.kts b/build.gradle.kts index 2fbb880..95bbce2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,6 +70,11 @@ tasks.withType { sourceCompatibility = "11" targetCompatibility = "11" } +tasks.withType>().configureEach { + compilerOptions { + freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") + } +} detekt { allRules = true diff --git a/gradle/scripts/detekt.yml b/gradle/scripts/detekt.yml index 724292a..9d8a4e3 100644 --- a/gradle/scripts/detekt.yml +++ b/gradle/scripts/detekt.yml @@ -15,8 +15,6 @@ performance: active: false style: - MandatoryBracesIfStatements: - active: false # OptionalWhenBraces: # active: false OptionalUnit: @@ -25,6 +23,8 @@ style: # active: false BracesOnIfStatements: active: false + BracesOnWhenStatements: + active: false comments: OutdatedDocumentation: diff --git a/src/commonMain/kotlin/ch/tutteli/kbox/dynamicTraversal.kt b/src/commonMain/kotlin/ch/tutteli/kbox/dynamicTraversal.kt new file mode 100644 index 0000000..8275698 --- /dev/null +++ b/src/commonMain/kotlin/ch/tutteli/kbox/dynamicTraversal.kt @@ -0,0 +1,132 @@ +package ch.tutteli.kbox + +import ch.tutteli.kbox.impl.DynamicDepthFirstTraversalSequence +import ch.tutteli.kbox.impl.DynamicBreadthFirstTraversalSequence +import kotlin.experimental.ExperimentalTypeInference +import kotlin.jvm.JvmName + +/** + * Returns a single sequence containing all elements [loadElements] recursively provides for the elements of the + * original sequence as well as the elements itself if [dropRoots] = false is specified. + * The operation is intermediate and stateless. + * + * For instance, in case of a tree structure, [loadElements] loads the children, in case of a graph the connected nodes. + * The order in which the elements are returned depends on the chosen [traversalAlgorithm] as well as [dropRoots]. + * (consult the documentation of the options to get more information). + * + * @param T the type of the elements in the sequence. + * + * @param revisit If set to true (default false), we no longer track visited elements and will re-[loadElements] in + * case of a revisit, potentially turning this into an infinite loop. + * Use this option only if you know that there aren't any cycles or in case the [loadElements] function is not + * referentially transparent, i.e. outcome might depend on other factors not only on input. + * @param dropRoots if set to false (default true) the resulting sequence also includes the elements of the original + * sequence. + * @param traversalAlgorithm the algorithm which should be used for traversal + * (default [TraversalAlgorithmOption.BreadthFirst]). + * @param loadElements the function returning the children of a given element in case of a tree / the connected nodes + * in case of a graph. + * + * @since 1.1.0 + */ +@OptIn(ExperimentalTypeInference::class) +@OverloadResolutionByLambdaReturnType +fun Sequence.dynamicTraversal( + revisit: Boolean = false, + dropRoots: Boolean = true, + traversalAlgorithm: TraversalAlgorithmOption = TraversalAlgorithmOption.BreadthFirst, + loadElements: (element: T) -> Sequence, +): Sequence = when (traversalAlgorithm) { + TraversalAlgorithmOption.BreadthFirst -> DynamicBreadthFirstTraversalSequence( + this, + loadElements, + Sequence::iterator, + revisit = revisit, + dropRoots = dropRoots + ) + + TraversalAlgorithmOption.DepthFirst -> DynamicDepthFirstTraversalSequence( + this, + loadElements, + Sequence::iterator, + revisit = revisit, + dropRoots = dropRoots + ) +} + +/** + * Returns a single sequence containing all elements [loadElements] recursively provides for the elements of the + * original sequence as well as the elements as such if [dropRoots] = false is specified. + * The operation is intermediate and stateless. + * + * For instance, in case of a tree structure, [loadElements] loads the children, in case of a graph the connected nodes. + * The order in which the elements are returned depends on the chosen [traversalAlgorithm] as well as [dropRoots]. + * (consult the documentation of the options to get more information). + * + * @param T the type of the elements in the sequence. + * + * @param revisit If set to true (default false), we no longer track visited elements and will re-[loadElements] in + * case of a revisit, potentially turning this into an infinite loop. + * Use this option only if you know that there aren't any cycles or in case the [loadElements] function is not + * referentially transparent, i.e. outcome might depend on other factors not only on input. + * @param dropRoots if set to false (default true) the resulting sequence also includes the elements of the original + * sequence. + * @param traversalAlgorithm the algorithm which should be used for traversal + * (default [TraversalAlgorithmOption.BreadthFirst]). + * @param loadElements the function returning the children of a given element in case of a tree / the connected nodes + * in case of a graph. + * + * @since 1.1.0 + */ +@OptIn(ExperimentalTypeInference::class) +@OverloadResolutionByLambdaReturnType +@JvmName("dynamicTraversalIterable") +fun Sequence.dynamicTraversal( + revisit: Boolean = false, + dropRoots: Boolean = true, + traversalAlgorithm: TraversalAlgorithmOption = TraversalAlgorithmOption.BreadthFirst, + loadElements: (element: T) -> Iterable, +): Sequence = when (traversalAlgorithm) { + TraversalAlgorithmOption.BreadthFirst -> DynamicBreadthFirstTraversalSequence( + this, + loadElements, + Iterable::iterator, + revisit = revisit, + dropRoots = dropRoots + ) + + TraversalAlgorithmOption.DepthFirst -> DynamicBreadthFirstTraversalSequence( + this, + loadElements, + Iterable::iterator, + revisit = revisit, + dropRoots = dropRoots + ) +} + +/** + * Represents the option to which traversal algorithm shall be applied. + * + * @since 1.1.0 + */ +enum class TraversalAlgorithmOption { + /** Taking the tree as example, the resulting sequence contains the result in a breath first manner, + * i.e. if `dropRoots = false` then the elements of the original sequence are returned first (if `dropRoots] = true` + * then they are skipped/dropped), then the children of those elements, then the children of the children and so on. + * + * @since 1.1.0 + */ + BreadthFirst, + + /** + * Taking the tree as example, the resulting sequence contains the result in a depth first manner, + * i.e. if `dropRoots = false` then the first element of the original sequence is returned first + * (if `dropRoots] = true` then this element is skipped/dropped). Next the first child of this first element is + * returned, then the first child of the first child etc. + * Once there is no first child (i.e. we reached a leaf of the tree) the sibling of this child is visited, and again + * the first child of this sibling etc. + * + * @since 1.1.0 + */ + DepthFirst +} diff --git a/src/commonMain/kotlin/ch/tutteli/kbox/impl/DynamicTreeTraversal.kt b/src/commonMain/kotlin/ch/tutteli/kbox/impl/DynamicTreeTraversal.kt new file mode 100644 index 0000000..3973fe7 --- /dev/null +++ b/src/commonMain/kotlin/ch/tutteli/kbox/impl/DynamicTreeTraversal.kt @@ -0,0 +1,117 @@ +package ch.tutteli.kbox.impl + + +internal class DynamicBreadthFirstTraversalSequence( + private val initialSequence: Sequence, + private val loadElements: (T) -> IterableLikeT, + private val iteratorProvider: (IterableLikeT) -> Iterator, + private val revisit: Boolean, + private val dropRoots: Boolean, +) : Sequence { + override fun iterator(): Iterator = object : DynamicTreeTraversalLikeIterator( + initialSequence, + loadElements, + iteratorProvider, + revisit = revisit, + dropRoots = dropRoots + ) { + override fun insertIterator(loadedChildrenIterator: Iterator) { + iteratorsToVisit.add(loadedChildrenIterator) + } + } +} + +internal class DynamicDepthFirstTraversalSequence( + private val initialSequence: Sequence, + private val loadChildren: (T) -> IterableLikeT, + private val iteratorProvider: (IterableLikeT) -> Iterator, + private val revisit: Boolean, + private val dropRoots: Boolean, +) : Sequence { + override fun iterator(): Iterator = object : DynamicTreeTraversalLikeIterator( + initialSequence, + loadChildren, + iteratorProvider, + revisit = revisit, + dropRoots = dropRoots + ) { + override fun insertIterator(loadedChildrenIterator: Iterator) = + // put on the stack so that we visit the children next (and not the siblings) + iteratorsToVisit.add(0, loadedChildrenIterator) + } +} + +internal abstract class DynamicTreeTraversalLikeIterator( + initialSequence: Sequence, + protected val loadElements: (T) -> IterableLikeT, + private val iteratorProvider: (IterableLikeT) -> Iterator, + private val revisit: Boolean, + private val dropRoots: Boolean +) : Iterator { + private val visitedElements = HashSet() + private val initialIterator = initialSequence.iterator() + protected val iteratorsToVisit = mutableListOf(initialIterator) + private var peek: Option = None + + override fun hasNext(): Boolean = hasVisitableIterator() + + override fun next(): T { + if (hasVisitableIterator().not()) throw NoSuchElementException() + return when (val p = peek) { + is None -> throw IllegalStateException( + "we just checked hasNext and now we don't have next element, concurrent access? " + + "This Iterator is not thread-safe" + ) + + is Some -> { + peek = None + p.value + } + } + } + + @Suppress( + // remove once Kotlin supports to inline functions + tailrec, then we could move something to another method + "NestedBlockDepth", + "CognitiveComplexMethod" + ) + private tailrec fun hasVisitableIterator(): Boolean = when { + peek is Some -> true + iteratorsToVisit.isEmpty() -> false + else -> { + val firstIterator = iteratorsToVisit[0] + if (firstIterator.hasNext()) { + val element = firstIterator.next() + if (revisit || element !in visitedElements) { + peek = Some(element) + if (revisit.not()) { + visitedElements.add(element) + } + loadElements(element) + .let(iteratorProvider) + .also(this::insertIterator) + + if (dropRoots && firstIterator == initialIterator) { + peek = None + hasVisitableIterator() + } else { + true + } + } else { + peek = None + hasVisitableIterator() + } + } else { + // pop the stack, visited all connected nodes + iteratorsToVisit.removeAt(0) + hasVisitableIterator() + } + } + } + + protected abstract fun insertIterator(loadedChildrenIterator: Iterator) +} + +internal sealed class Option +internal object None : Option() +internal class Some(val value: T) : Option() diff --git a/src/commonTest/kotlin/ch/tutteli/kbox/DynamicTreeTraversalSpec.kt b/src/commonTest/kotlin/ch/tutteli/kbox/DynamicTreeTraversalSpec.kt new file mode 100644 index 0000000..44183f7 --- /dev/null +++ b/src/commonTest/kotlin/ch/tutteli/kbox/DynamicTreeTraversalSpec.kt @@ -0,0 +1,192 @@ +package ch.tutteli.kbox + +import ch.tutteli.atrium.api.fluent.en_GB.* +import ch.tutteli.kbox.atrium.expect +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +object DynamicTreeTraversalSpec : Spek({ + + describe("empty sequence") { + TraversalAlgorithmOption.values().flatMap { algo -> + listOf(false, true).flatMap { revisit -> + listOf(false, true).map { dropRoots -> + Triple(algo, revisit, dropRoots) + } + } + }.forEach { (algo, revisit, dropRoots) -> + it("algo: $algo, revisit: $revisit, dropRoots: $dropRoots") { + expect( + emptySequence().dynamicTraversal( + revisit = revisit, + dropRoots = dropRoots, + traversalAlgorithm = algo, + ) { + sequenceOf(1) + } + ).asIterable().notToHaveElements() + } + } + } + + describe("sequenceOf 1, 3") { + describe("load elements returns sequence of it + 1") { + listOf( + Triple( + TraversalAlgorithmOption.BreadthFirst, false, true + ) to + //visiting rounds: + // 1, 3 (drop roots, so they don't show up) + // 2, 4 + // 3, 5 (3 not returned as already visited) + // , 6 + listOf(2, 4, 5, 6, 7), + + Triple( + TraversalAlgorithmOption.BreadthFirst, false, false + ) to listOf(1, 3, 2, 4, 5), + + Triple( + TraversalAlgorithmOption.BreadthFirst, true, true + ) to + //visiting rounds: + // 1, 3 (drop roots, so they don't show up) + // 2, 4 + // 3, 5 (3 revisited) + // 4 (4 revisited) + listOf(2, 4, 3, 5, 4), + + Triple( + TraversalAlgorithmOption.BreadthFirst, true, false + ) to listOf(1, 3, 2, 4, 3), + + Triple( + TraversalAlgorithmOption.DepthFirst, false, true + ) to listOf(2, 3, 4, 5, 6), + + Triple( + TraversalAlgorithmOption.DepthFirst, false, false + ) to listOf(1, 2, 3, 4, 5), + + Triple( + TraversalAlgorithmOption.DepthFirst, true, true + ) to listOf(2, 3, 4, 5, 6), + + Triple( + TraversalAlgorithmOption.DepthFirst, true, false + ) to listOf(1, 2, 3, 4, 5), + ).forEach { (triple, expected) -> + val (algo, revisit, dropRoots) = triple + it("algo: $algo, revisit: $revisit, dropRoots: $dropRoots => take 5 returns ${expected.joinToString(", ")}") { + var saw3 = false + expect( + sequenceOf(1, 3).dynamicTraversal( + revisit = revisit, + dropRoots = dropRoots, + traversalAlgorithm = algo, + ) { + if (revisit.not() && it == 3) { + if (saw3) throw IllegalStateException("visiting 3 for the second time") + else saw3 = true + } + sequenceOf(it + 1) + }.take(5).toList() + ).toContainExactlyElementsOf(expected) + } + } + } + + describe("initial sequence throws when loading 3") { + val throwingSequence = Sequence { + object : Iterator { + var isFirst = true + override fun hasNext() = true + + override fun next(): Int = + if (isFirst) { + isFirst = false + 1 + } else throw IllegalStateException("unexpected error") + } + } + listOf(false, true).flatMap { revisit -> + listOf(false, true).map { dropRoots -> + Triple(TraversalAlgorithmOption.BreadthFirst, revisit, dropRoots) + } + }.forEach { (algo, revisit, dropRoots) -> + it("algo: $algo, revisit: $revisit, dropRoots: $dropRoots => throws as it reads it") { + expect { + throwingSequence.dynamicTraversal( + revisit = revisit, + dropRoots = dropRoots, + traversalAlgorithm = algo, + ) { + sequenceOf(it + 1) + }.take(5).toList() + }.toThrow { + messageToContain("unexpected error") + } + } + } + listOf(false, true).flatMap { revisit -> + listOf(false, true).map { dropRoots -> + Triple(TraversalAlgorithmOption.DepthFirst, revisit, dropRoots) + } + }.forEach { (algo, revisit, dropRoots) -> + it("algo: $algo, revisit: $revisit, dropRoots: $dropRoots => doesn't throw as it never reaches it") { + expect { + throwingSequence.dynamicTraversal( + revisit = revisit, + dropRoots = dropRoots, + traversalAlgorithm = algo, + ) { + sequenceOf(it + 1) + }.take(5).toList() + }.notToThrow().let { + if (dropRoots) { + it.toContainExactly(2, 3, 4, 5, 6) + } else { + it.toContainExactly(1, 2, 3, 4, 5) + } + } + } + } + } + + describe("load elements returns only for initial sequence it + 1, it + 2, then empty sequence") { + listOf( + + Triple( + TraversalAlgorithmOption.BreadthFirst, true, true + ) to listOf(2 to false, 3 to false, 4 to false, 5 to false), + + Triple( + TraversalAlgorithmOption.BreadthFirst, true, false + ) to listOf(1 to true, 3 to true, 2 to false, 3 to false, 4 to false), + + Triple( + TraversalAlgorithmOption.DepthFirst, true, true + ) to listOf(2 to false, 3 to false, 4 to false, 5 to false), + + Triple( + TraversalAlgorithmOption.DepthFirst, true, false + ) to listOf(1 to true, 2 to false, 3 to false, 3 to true, 4 to false), + ).forEach { (triple, expected) -> + val (algo, revisit, dropRoots) = triple + it("algo: $algo, revisit: $revisit, dropRoots: $dropRoots => take 5 returns ${expected.joinToString(", ")}") { + expect( + sequenceOf(1 to true, 3 to true).dynamicTraversal( + revisit = revisit, + dropRoots = dropRoots, + traversalAlgorithm = algo + ) { (num, isFirst) -> + if (isFirst) { + sequenceOf(num + 1 to false, num + 2 to false) + } else emptySequence() + }.take(5).toList() + ).toContainExactlyElementsOf(expected) + } + } + } + } +})