diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c2172..107a384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to this project are documented here. The format is based on `moveChild`, `addChildren`, and `sortChildren`. - Tree query extensions: `lowestCommonAncestor`, `distance`, `pathBetween`, and `contains` for finding common ancestors, edge distances, the path between two nodes, and value membership. +- Customizable `prettyString(connectors, render)` extension: choose connector glyphs via + `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()`. ### Changed - Rewrote the README for clarity: one consistent example tree, task-oriented sections diff --git a/api/tree-structure.api b/api/tree-structure.api index 751d184..6212ff1 100644 --- a/api/tree-structure.api +++ b/api/tree-structure.api @@ -6,6 +6,29 @@ public final class com/github/adriankuta/datastructure/tree/ChildDeclarationInte public static synthetic fun child$default (Lcom/github/adriankuta/datastructure/tree/ChildDeclarationInterface;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/github/adriankuta/datastructure/tree/TreeNode; } +public final class com/github/adriankuta/datastructure/tree/TreeConnectors { + public static final field Companion Lcom/github/adriankuta/datastructure/tree/TreeConnectors$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/github/adriankuta/datastructure/tree/TreeConnectors; + public static synthetic fun copy$default (Lcom/github/adriankuta/datastructure/tree/TreeConnectors;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/github/adriankuta/datastructure/tree/TreeConnectors; + public fun equals (Ljava/lang/Object;)Z + public final fun getBranch ()Ljava/lang/String; + public final fun getEmpty ()Ljava/lang/String; + public final fun getLastBranch ()Ljava/lang/String; + public final fun getVertical ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/adriankuta/datastructure/tree/TreeConnectors$Companion { + public final fun getAscii ()Lcom/github/adriankuta/datastructure/tree/TreeConnectors; + public final fun getDefault ()Lcom/github/adriankuta/datastructure/tree/TreeConnectors; +} + public class com/github/adriankuta/datastructure/tree/TreeNode : com/github/adriankuta/datastructure/tree/ChildDeclarationInterface, java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker { 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 @@ -57,6 +80,11 @@ public final class com/github/adriankuta/datastructure/tree/TreeNodeNavigationEx public static final fun siblings (Lcom/github/adriankuta/datastructure/tree/TreeNode;)Ljava/util/List; } +public final class com/github/adriankuta/datastructure/tree/TreeNodePrettyPrintExtKt { + public static final fun prettyString (Lcom/github/adriankuta/datastructure/tree/TreeNode;Lcom/github/adriankuta/datastructure/tree/TreeConnectors;Lkotlin/jvm/functions/Function3;)Ljava/lang/String; + public static synthetic fun prettyString$default (Lcom/github/adriankuta/datastructure/tree/TreeNode;Lcom/github/adriankuta/datastructure/tree/TreeConnectors;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Ljava/lang/String; +} + public final class com/github/adriankuta/datastructure/tree/TreeNodeQueryExtKt { public static final fun contains (Lcom/github/adriankuta/datastructure/tree/TreeNode;Ljava/lang/Object;)Z public static final fun distance (Lcom/github/adriankuta/datastructure/tree/TreeNode;Lcom/github/adriankuta/datastructure/tree/TreeNode;)Ljava/lang/Integer; diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodePrettyPrintExt.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodePrettyPrintExt.kt new file mode 100644 index 0000000..42ca4be --- /dev/null +++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodePrettyPrintExt.kt @@ -0,0 +1,106 @@ +package com.github.adriankuta.datastructure.tree + +/** + * The four glyph strings used to draw the tree branches in [prettyString]. + * + * Each value is the literal text emitted at the matching position: + * - [branch] precedes a child that is **not** its parent's last child. + * - [lastBranch] precedes a child that **is** its parent's last child. + * - [vertical] is accumulated into the prefix of the descendants of a non-last child (it keeps the + * vertical guide line going). + * - [empty] is accumulated into the prefix of the descendants of a last child (no guide line is + * needed past the last branch). + * + * Use [Default] for the box-drawing style or [Ascii] for a plain-ASCII style, or supply your own. + * + * @property branch drawn before a non-last child. + * @property vertical continuation prefix for descendants of a non-last child. + * @property lastBranch drawn before the last child. + * @property empty continuation prefix for descendants of a last child. + */ +public data class TreeConnectors( + public val branch: String, + public val vertical: String, + public val lastBranch: String, + public val empty: String, +) { + public companion object { + /** Box-drawing connectors, matching the output of the no-arg [TreeNode.prettyString]. */ + public val Default: TreeConnectors = TreeConnectors( + branch = "├── ", + vertical = "│ ", + lastBranch = "└── ", + empty = " ", + ) + + /** Plain-ASCII connectors for terminals or fonts that lack box-drawing glyphs. */ + public val Ascii: TreeConnectors = TreeConnectors( + branch = "|-- ", + vertical = "| ", + lastBranch = "`-- ", + empty = " ", + ) + } +} + +/** + * Renders this subtree as a multi-line string, one node per line, with branch connectors. + * + * Calling this with all defaults produces output byte-identical to the no-arg member + * [TreeNode.prettyString]. Customise the drawing with [connectors] (e.g. [TreeConnectors.Ascii]) and + * the per-node text with [render]. + * + * @param connectors the glyph set used to draw the branches. Defaults to [TreeConnectors.Default]. + * @param render produces the text for each node from its `value`, its `depth` (distance from this + * receiver, which is `0`) and `isLast` (whether the node is its parent's last child; the root is + * considered `true`). Defaults to the value's string form (`"$value"`), which renders a `null` + * value as `"null"` to match the no-arg member. + * @return the rendered tree, each line terminated by `\n`. + */ +public fun TreeNode.prettyString( + connectors: TreeConnectors = TreeConnectors.Default, + render: (value: T, depth: Int, isLast: Boolean) -> String = { value, _, _ -> "$value" }, +): String { + val stringBuilder = StringBuilder() + appendPretty(stringBuilder, "", "", 0, true, connectors, render) + return stringBuilder.toString() +} + +private fun TreeNode.appendPretty( + stringBuilder: StringBuilder, + prefix: String, + childrenPrefix: String, + depth: Int, + isLast: Boolean, + connectors: TreeConnectors, + render: (value: T, depth: Int, isLast: Boolean) -> String, +) { + stringBuilder.append(prefix) + stringBuilder.append(render(value, depth, isLast)) + stringBuilder.append('\n') + val childIterator = children.iterator() + while (childIterator.hasNext()) { + val node = childIterator.next() + if (childIterator.hasNext()) { + node.appendPretty( + stringBuilder, + childrenPrefix + connectors.branch, + childrenPrefix + connectors.vertical, + depth + 1, + false, + connectors, + render, + ) + } else { + node.appendPretty( + stringBuilder, + childrenPrefix + connectors.lastBranch, + childrenPrefix + connectors.empty, + depth + 1, + true, + connectors, + render, + ) + } + } +} diff --git a/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodePrettyPrintTest.kt b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodePrettyPrintTest.kt new file mode 100644 index 0000000..5e8ca30 --- /dev/null +++ b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodePrettyPrintTest.kt @@ -0,0 +1,100 @@ +package com.github.adriankuta.datastructure.tree + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TreeNodePrettyPrintTest { + + private fun sampleTree(): TreeNode { + val root = TreeNode("Root") + val beverages = TreeNode("Beverages") + val curd = TreeNode("Curd") + root.addChild(beverages) + root.addChild(curd) + + val tea = TreeNode("tea") + val coffee = TreeNode("coffee") + beverages.addChild(tea) + beverages.addChild(coffee) + + tea.addChild(TreeNode("ginger tea")) + tea.addChild(TreeNode("normal tea")) + + curd.addChild(TreeNode("yogurt")) + curd.addChild(TreeNode("lassi")) + return root + } + + @Test + fun defaultConnectorsMatchMemberPrettyString() { + val root = sampleTree() + assertEquals(root.prettyString(), root.prettyString(connectors = TreeConnectors.Default)) + } + + @Test + fun defaultRenderMatchesMemberForNullValues() { + // The member prettyString() appends the value via StringBuilder, rendering null as "null". + // The all-defaults extension must stay byte-identical, including for null-valued nodes. + val root = TreeNode(null) + root.addChild(TreeNode("child")) + root.addChild(TreeNode(null)) + assertEquals(root.prettyString(), root.prettyString(connectors = TreeConnectors.Default)) + assertEquals( + "null\n" + + "├── child\n" + + "└── null\n", + root.prettyString(), + ) + } + + @Test + fun asciiConnectorsRenderPlainAscii() { + val root = sampleTree() + assertEquals( + "Root\n" + + "|-- Beverages\n" + + "| |-- tea\n" + + "| | |-- ginger tea\n" + + "| | `-- normal tea\n" + + "| `-- coffee\n" + + "`-- Curd\n" + + " |-- yogurt\n" + + " `-- lassi\n", + root.prettyString(connectors = TreeConnectors.Ascii), + ) + } + + @Test + fun customRenderIsApplied() { + val root = sampleTree() + assertEquals( + "ROOT\n" + + "├── BEVERAGES\n" + + "│ ├── TEA\n" + + "│ │ ├── GINGER TEA\n" + + "│ │ └── NORMAL TEA\n" + + "│ └── COFFEE\n" + + "└── CURD\n" + + " ├── YOGURT\n" + + " └── LASSI\n", + root.prettyString { value, _, _ -> value.uppercase() }, + ) + } + + @Test + fun depthAndIsLastArePassedToRender() { + val root = sampleTree() + assertEquals( + "Root depth=0 last=true\n" + + "├── Beverages depth=1 last=false\n" + + "│ ├── tea depth=2 last=false\n" + + "│ │ ├── ginger tea depth=3 last=false\n" + + "│ │ └── normal tea depth=3 last=true\n" + + "│ └── coffee depth=2 last=true\n" + + "└── Curd depth=1 last=true\n" + + " ├── yogurt depth=2 last=false\n" + + " └── lassi depth=2 last=true\n", + root.prettyString { value, depth, isLast -> "$value depth=$depth last=$isLast" }, + ) + } +}