Skip to content

Commit 2abcec7

Browse files
committed
experimental TOON renderer and DF->TOON converter
1 parent c411a9f commit 2abcec7

File tree

3 files changed

+530
-466
lines changed

3 files changed

+530
-466
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
package org.jetbrains.kotlinx.dataframe.impl.io
2+
3+
public enum class ToonDelimiter(internal val value: Char) {
4+
COMMA(','),
5+
TAB('\t'),
6+
PIPE('|'),
7+
}
8+
9+
internal sealed interface ToonElement
10+
11+
internal fun ToonElement.render(
12+
delimiter: ToonDelimiter = ToonDelimiter.COMMA,
13+
indentStart: Int = 0,
14+
indentStep: Int = 2,
15+
): String =
16+
when (this) {
17+
is ToonPrimitive<*> -> render(delimiter)
18+
is ToonArray -> render(delimiter, indentStart, indentStep)
19+
is ToonObject -> render(delimiter, indentStart, indentStep)
20+
}
21+
22+
internal fun ToonElement.asPrimitive() = this as ToonPrimitive<*>
23+
24+
internal fun ToonElement.asObject() = this as ToonObject
25+
26+
internal fun ToonElement.asArray() = this as ToonArray
27+
28+
// region Primitives
29+
30+
internal sealed interface ToonPrimitive<T : Any?> : ToonElement {
31+
32+
val value: T
33+
34+
fun render(delimiter: ToonDelimiter): String
35+
36+
companion object {
37+
val NULL = ToonNull
38+
39+
operator fun invoke(value: Nothing?): ToonNull = ToonNull
40+
41+
operator fun invoke(value: Boolean): ToonBoolean = ToonBoolean(value)
42+
43+
operator fun invoke(value: Number): ToonNumber = ToonNumber(value)
44+
45+
operator fun invoke(value: String): ToonString = ToonString(value)
46+
47+
operator fun invoke(value: Any?): ToonPrimitive<*> =
48+
when (value) {
49+
null -> ToonNull
50+
is Number -> ToonNumber(value)
51+
is Boolean -> ToonBoolean(value)
52+
is String -> ToonString(value)
53+
else -> ToonString(value.toString()) // default to string
54+
}
55+
}
56+
}
57+
58+
internal data object ToonNull : ToonPrimitive<Nothing?> {
59+
override val value: Nothing? = null
60+
61+
override fun render(delimiter: ToonDelimiter): String = "null"
62+
}
63+
64+
@JvmInline
65+
internal value class ToonString(override val value: String) : ToonPrimitive<String> {
66+
67+
private companion object {
68+
private val disallowedToonChars = setOf('\n', '\r', '\t', '"', '{', '}', '[', ']', ':', '\\')
69+
70+
private fun String.looksLikeNumber() =
71+
matches("""/^-?\d+(?:.\d+)?(?:e[+-]?\d+)?$/i""".toRegex()) ||
72+
matches("""/^0\d+$/""".toRegex())
73+
74+
private fun String.needsQuotes(activeDelimiter: ToonDelimiter): Boolean =
75+
isEmpty() ||
76+
trim().length != length ||
77+
this == "true" ||
78+
this == "false" ||
79+
this == "null" ||
80+
looksLikeNumber() ||
81+
any { it in disallowedToonChars } ||
82+
contains(activeDelimiter.value) ||
83+
startsWith('-')
84+
}
85+
86+
override fun render(delimiter: ToonDelimiter): String =
87+
when {
88+
value.needsQuotes(delimiter) -> "\"$value\""
89+
else -> value
90+
}
91+
}
92+
93+
@JvmInline
94+
internal value class ToonNumber(override val value: Number) : ToonPrimitive<Number> {
95+
override fun render(delimiter: ToonDelimiter): String = value.toString()
96+
}
97+
98+
@JvmInline
99+
internal value class ToonBoolean(override val value: Boolean) : ToonPrimitive<Boolean> {
100+
override fun render(delimiter: ToonDelimiter): String = value.toString()
101+
}
102+
103+
// endregion
104+
105+
// region Object
106+
107+
/**
108+
* id: 123
109+
* name: Ada
110+
* active[2]: true,false
111+
*
112+
* or
113+
*
114+
* user:
115+
* id: 123
116+
* name: Ada
117+
*/
118+
internal class ToonObject(val map: Map<String, ToonElement>) : ToonElement {
119+
// can be made tabular if flat
120+
val isFlat: Boolean = map.values.all { it is ToonPrimitive<*> }
121+
122+
fun String.needsQuotes() = !matches("""^[A-Za-z_][A-Za-z0-9_.]*$""".toRegex())
123+
124+
// TODO? key folding
125+
fun render(delimiter: ToonDelimiter, indentStart: Int, indentStep: Int): String =
126+
buildString {
127+
var isFirst = true
128+
for ((key, value) in map) {
129+
// ignore indents for the first key-value pair
130+
if (isFirst) {
131+
isFirst = false
132+
} else {
133+
append(indent(indentStart))
134+
}
135+
append(
136+
when {
137+
key.needsQuotes() -> "\"$key\""
138+
else -> key
139+
},
140+
)
141+
when (value) {
142+
// id: 123
143+
is ToonPrimitive<*> -> appendLine(": ${value.render(delimiter)}")
144+
145+
// active[2]: true,false
146+
//
147+
// items[2]{sku,qty,price}:
148+
// A1,2,9.99
149+
// B2,1,14.5
150+
//
151+
// items[3]:
152+
// - 1
153+
// - a: 1
154+
// b: 3
155+
// - text
156+
is ToonArray -> append(
157+
value.render(
158+
delimiter = delimiter,
159+
indentStart = indentStart,
160+
indentStep = indentStep,
161+
),
162+
)
163+
164+
// user:
165+
// id: 123
166+
// name: Ada
167+
is ToonObject -> append(
168+
":\n${indent(indentStart + indentStep)}${
169+
value.render(
170+
delimiter = delimiter,
171+
indentStart = indentStart + indentStep,
172+
indentStep = indentStep,
173+
)
174+
}",
175+
)
176+
}
177+
}
178+
}
179+
}
180+
181+
// endregion
182+
183+
// region Arrays
184+
185+
internal sealed interface ToonArray : ToonElement {
186+
val size: Int
187+
188+
fun render(delimiter: ToonDelimiter, indentStart: Int, indentStep: Int): String
189+
190+
companion object {
191+
@JvmName("ofPrimitives")
192+
operator fun invoke(primitives: List<ToonPrimitive<*>>): ToonArray = ToonInlineArray(values = primitives)
193+
194+
@JvmName("ofObjectArray")
195+
operator fun invoke(header: List<String>, objectArray: List<List<ToonPrimitive<*>>>): ToonArray {
196+
require(objectArray.all { it.size == header.size }) {
197+
"All object rows must have the same number of columns as the header"
198+
}
199+
return ToonTabularArray(header = header, objectArray = objectArray)
200+
}
201+
202+
@JvmName("ofObjects")
203+
operator fun invoke(objects: List<ToonObject>): ToonArray =
204+
objects.toTabularOrNull()
205+
?: ToonMixedArray(values = objects)
206+
207+
@JvmName("ofElements")
208+
@Suppress("UNCHECKED_CAST")
209+
operator fun invoke(values: List<ToonElement>): ToonArray =
210+
when {
211+
values.all { it is ToonPrimitive<*> } ->
212+
invoke(primitives = values as List<ToonPrimitive<*>>)
213+
214+
values.all { it is ToonObject } -> invoke(objects = values as List<ToonObject>)
215+
216+
else -> ToonMixedArray(values = values)
217+
}
218+
219+
private fun List<ToonObject>.toTabularOrNull(): ToonTabularArray? {
220+
if (isEmpty()) return ToonTabularArray(emptyList(), emptyList())
221+
222+
val header = first().map.keys
223+
if (!all { it.map.keys == header }) return null
224+
if (any { !it.isFlat }) return null
225+
226+
val objectArray = map { it.map.values.toList() as List<ToonPrimitive<*>> }
227+
return ToonTabularArray(header = header.toList(), objectArray = objectArray)
228+
}
229+
}
230+
}
231+
232+
/**
233+
* tags[3]: admin,ops,dev
234+
*/
235+
private class ToonInlineArray(val values: List<ToonPrimitive<*>>) : ToonArray {
236+
override val size: Int = values.size
237+
238+
override fun render(delimiter: ToonDelimiter, indentStart: Int, indentStep: Int): String =
239+
buildString {
240+
append("[$size")
241+
if (delimiter != ToonDelimiter.COMMA) append(delimiter.value)
242+
append("]: ")
243+
appendLine(
244+
values.joinToString(delimiter.value.toString()) { it.render(delimiter) },
245+
)
246+
}
247+
}
248+
249+
/**
250+
* items[2]{sku,qty,price}:
251+
* A1,2,9.99
252+
* B2,1,14.5
253+
*/
254+
private class ToonTabularArray(val header: List<String>, val objectArray: List<List<ToonPrimitive<*>>>) : ToonArray {
255+
override val size: Int = objectArray.size
256+
257+
override fun render(delimiter: ToonDelimiter, indentStart: Int, indentStep: Int): String =
258+
buildString {
259+
append("[$size")
260+
if (delimiter != ToonDelimiter.COMMA) append(delimiter.value)
261+
append("]{")
262+
append(header.joinToString(delimiter.value.toString()))
263+
appendLine("}:")
264+
265+
val valueIndent = indentStart + indentStep
266+
for (row in objectArray) {
267+
append(indent(valueIndent))
268+
appendLine(
269+
row.joinToString(delimiter.value.toString()) { it.render(delimiter) },
270+
)
271+
}
272+
}
273+
}
274+
275+
/**
276+
* items[3]:
277+
* - 1
278+
* - a: 1
279+
* - text
280+
*/
281+
private class ToonMixedArray(val values: List<ToonElement>) : ToonArray {
282+
override val size: Int = values.size
283+
284+
override fun render(delimiter: ToonDelimiter, indentStart: Int, indentStep: Int): String =
285+
buildString {
286+
append("[$size")
287+
if (delimiter != ToonDelimiter.COMMA) append(delimiter.value)
288+
appendLine("]:")
289+
290+
val valueIndent = indentStart + indentStep
291+
for (value in values) {
292+
append(indent(valueIndent))
293+
append("- ")
294+
append(
295+
value.render(
296+
delimiter = delimiter,
297+
indentStart = valueIndent + indentStep,
298+
indentStep = indentStep,
299+
),
300+
)
301+
if (value is ToonPrimitive<*>) appendLine()
302+
}
303+
}
304+
}
305+
306+
// endregion
307+
308+
private fun indent(size: Int) = " ".repeat(size)

0 commit comments

Comments
 (0)