Skip to content

Commit de2c7a9

Browse files
committed
refactor: integrate CompactZeros into PayloadTransform, remove compactZeros flag, update EncodedFormat usage and tests
1 parent 152d1e8 commit de2c7a9

File tree

3 files changed

+108
-80
lines changed

3 files changed

+108
-80
lines changed

src/commonMain/kotlin/com/eignex/kencode/EncodedFormat.kt

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,26 @@ import kotlinx.serialization.modules.SerializersModule
88
*
99
* @property codec The ASCII-safe byte codec used to turn raw bytes into text (e.g., Base62, Base64).
1010
* @property transform An optional [PayloadTransform] applied after serialization and before encoding.
11-
* Common uses: integrity checks via [Checksum.asTransform], encryption, or error-correcting codes.
11+
* Common uses: [CompactZeros] to strip leading zeros, integrity checks via [Checksum.asTransform],
12+
* encryption, or error-correcting codes. Use [PayloadTransform.then] to chain multiple transforms.
1213
* @property binaryFormat The underlying binary serialization format used before encoding to text.
13-
* @property compactZeros When true, leading zero bytes are stripped before encoding and restored on decode.
14-
* A varint prefix encodes the count, costing 1 byte for up to 127 stripped bytes.
1514
*/
1615
data class EncodedConfiguration(
1716
val codec: ByteEncoding = Base62,
1817
val transform: PayloadTransform? = null,
1918
val binaryFormat: BinaryFormat = PackedFormat.Default,
20-
val compactZeros: Boolean = false,
2119
)
2220

2321
/**
2422
* Text `StringFormat` that produces short, predictable string tokens by composing:
2523
*
2624
* 1. A binary format (e.g. [PackedFormat], `ProtoBuf`).
27-
* 2. An optional [PayloadTransform] (checksum, encryption, ECC, …).
25+
* 2. An optional [PayloadTransform] ([CompactZeros], checksum, encryption, ECC, …).
2826
* 3. An ASCII-safe byte encoding (e.g. [Base62], [Base64], [Base36], [Base85]).
2927
*
3028
* Typical use:
31-
* - `encodeToString`: serialize -> transform.encode -> (optionally) compact zeros -> encode bytes to text.
32-
* - `decodeFromString`: decode text to bytes -> (optionally) restore zeros -> transform.decode -> deserialize.
29+
* - `encodeToString`: serialize -> transform.encode -> encode bytes to text.
30+
* - `decodeFromString`: decode text to bytes -> transform.decode -> deserialize.
3331
*
3432
* Use the [EncodedFormat] builder function to create a customized instance.
3533
*
@@ -47,16 +45,15 @@ open class EncodedFormat(
4745
codec: ByteEncoding = Base62,
4846
transform: PayloadTransform? = null,
4947
binaryFormat: BinaryFormat = PackedFormat,
50-
compactZeros: Boolean = true,
51-
) : this(EncodedConfiguration(codec, transform, binaryFormat, compactZeros))
48+
) : this(EncodedConfiguration(codec, transform, binaryFormat))
5249

5350
/**
5451
* Delegates to the underlying [BinaryFormat]'s serializers module.
5552
*/
5653
override val serializersModule: SerializersModule get() = configuration.binaryFormat.serializersModule
5754

5855
/**
59-
* Default format: `PackedFormat` + `Base62` without a transform.
56+
* Default format: `PackedFormat` + `Base62` + [CompactZeros].
6057
*/
6158
companion object Default : EncodedFormat()
6259

@@ -66,8 +63,7 @@ open class EncodedFormat(
6663
*/
6764
override fun <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
6865
val bytes = configuration.binaryFormat.encodeToByteArray(serializer, value)
69-
val transformed = configuration.transform?.encode(bytes) ?: bytes
70-
val payload = if (configuration.compactZeros) compactZerosEncode(transformed) else transformed
66+
val payload = configuration.transform?.encode(bytes) ?: bytes
7167
return configuration.codec.encode(payload)
7268
}
7369

@@ -79,47 +75,18 @@ open class EncodedFormat(
7975
*/
8076
override fun <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
8177
val input = configuration.codec.decode(string)
82-
val raw = if (configuration.compactZeros) compactZerosDecode(input) else input
83-
val bytes = configuration.transform?.decode(raw) ?: raw
78+
val bytes = configuration.transform?.decode(input) ?: input
8479
return configuration.binaryFormat.decodeFromByteArray(deserializer, bytes)
8580
}
86-
87-
private fun compactZerosEncode(bytes: ByteArray): ByteArray {
88-
var k = 0
89-
while (k < bytes.size && bytes[k] == 0.toByte()) k++
90-
val prefix = varintEncode(k)
91-
val result = ByteArray(prefix.size + bytes.size - k)
92-
prefix.copyInto(result)
93-
bytes.copyInto(result, destinationOffset = prefix.size, startIndex = k)
94-
return result
95-
}
96-
97-
private fun compactZerosDecode(bytes: ByteArray): ByteArray {
98-
require(bytes.isNotEmpty()) { "Compact payload cannot be empty." }
99-
val (k, prefixLen) = varintDecode(bytes)
100-
val result = ByteArray(k + bytes.size - prefixLen)
101-
bytes.copyInto(result, destinationOffset = k, startIndex = prefixLen)
102-
return result
103-
}
104-
105-
private fun varintEncode(value: Int): ByteArray {
106-
val out = ByteOutput(5)
107-
PackedUtils.writeVarInt(value, out)
108-
return out.toByteArray()
109-
}
110-
111-
private fun varintDecode(bytes: ByteArray): Pair<Int, Int> =
112-
PackedUtils.decodeVarInt(bytes, 0)
11381
}
11482

11583
/**
11684
* Builder for configuring [EncodedFormat] instances.
11785
*/
11886
class EncodedFormatBuilder {
11987
var codec: ByteEncoding = Base62
120-
var transform: PayloadTransform? = null
88+
var transform: PayloadTransform? = CompactZeros
12189
var binaryFormat: BinaryFormat = PackedFormat.Default
122-
var compactZeros: Boolean = true
12390

12491
var checksum: Checksum?
12592
get() = null
@@ -132,7 +99,7 @@ class EncodedFormatBuilder {
13299
* ```
133100
* val format = EncodedFormat {
134101
* codec = Base36
135-
* transform = Crc16.asTransform()
102+
* transform = CompactZeros.then(Crc16.asTransform())
136103
* }
137104
* ```
138105
*
@@ -147,13 +114,11 @@ fun EncodedFormat(
147114
codec = from.configuration.codec
148115
transform = from.configuration.transform
149116
binaryFormat = from.configuration.binaryFormat
150-
compactZeros = from.configuration.compactZeros
151117
}
152118
builder.builderAction()
153119
return EncodedFormat(EncodedConfiguration(
154120
builder.codec,
155121
builder.transform,
156122
builder.binaryFormat,
157-
builder.compactZeros,
158123
))
159124
}

src/commonMain/kotlin/com/eignex/kencode/PayloadTransform.kt

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,59 @@ interface PayloadTransform {
1313
fun decode(data: ByteArray): ByteArray
1414
}
1515

16+
/**
17+
* Chains two transforms into a pipeline.
18+
*
19+
* On encode: `this` is applied first, then [next].
20+
* On decode: [next] is reversed first, then `this`.
21+
*
22+
* Example: `CompactZeros.then(Crc16.asTransform())` compacts bytes, then appends a checksum.
23+
*/
24+
fun PayloadTransform.then(next: PayloadTransform): PayloadTransform = object : PayloadTransform {
25+
override fun encode(data: ByteArray): ByteArray = next.encode(this@then.encode(data))
26+
override fun decode(data: ByteArray): ByteArray = this@then.decode(next.decode(data))
27+
}
28+
29+
/**
30+
* Strips leading zero bytes before encoding and restores them on decode.
31+
*
32+
* When the payload starts with a non-zero byte (k=0), the data is returned unchanged.
33+
* Otherwise a sentinel `0x00` byte followed by a varint count is prepended to the
34+
* stripped payload. Net overhead: 0 bytes for k=0, +1 byte for k=2, −(k−2) bytes for k≥3.
35+
*/
36+
object CompactZeros : PayloadTransform {
37+
38+
override fun encode(data: ByteArray): ByteArray {
39+
var k = 0
40+
while (k < data.size && data[k] == 0.toByte()) k++
41+
if (k == 0) return data
42+
// Sentinel byte 0x00 signals that a compact prefix follows.
43+
// Safe because the stripped data always starts with a non-zero byte.
44+
val count = varintEncode(k)
45+
val result = ByteArray(1 + count.size + data.size - k)
46+
result[0] = 0x00
47+
count.copyInto(result, destinationOffset = 1)
48+
data.copyInto(result, destinationOffset = 1 + count.size, startIndex = k)
49+
return result
50+
}
51+
52+
override fun decode(data: ByteArray): ByteArray {
53+
require(data.isNotEmpty()) { "Compact payload cannot be empty." }
54+
if (data[0] != 0.toByte()) return data // k=0, no prefix was written
55+
val (k, prefixLen) = PackedUtils.decodeVarInt(data, 1)
56+
val dataStart = 1 + prefixLen
57+
val result = ByteArray(k + data.size - dataStart)
58+
data.copyInto(result, destinationOffset = k, startIndex = dataStart)
59+
return result
60+
}
61+
62+
private fun varintEncode(value: Int): ByteArray {
63+
val out = ByteOutput(5)
64+
PackedUtils.writeVarInt(value, out)
65+
return out.toByteArray()
66+
}
67+
}
68+
1669
/**
1770
* Wraps this [Checksum] as a [PayloadTransform] that appends the digest on encode
1871
* and strips and verifies it on decode.

src/commonTest/kotlin/com/eignex/kencode/EncodedFormatTest.kt

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,53 +9,47 @@ class EncodedFormatTest {
99
@Serializable
1010
data class Payload(val x: Int, val s: String)
1111

12-
private val formatNoChecksum: StringFormat = EncodedFormat(Base62, null)
13-
private val formatWithChecksum: StringFormat = EncodedFormat(Base62, Crc16.asTransform())
12+
private val formatBare: StringFormat = EncodedFormat(Base62, null)
13+
private val formatChecksum: StringFormat = EncodedFormat(Base62, Crc16.asTransform())
14+
private val formatCompact: StringFormat = EncodedFormat(Base62, CompactZeros)
15+
private val formatCompactChecksum: StringFormat = EncodedFormat(Base62, CompactZeros.then(Crc16.asTransform()))
1416

1517
@Test
16-
fun `roundtrip without checksum`() {
18+
fun `roundtrip without transform`() {
1719
val value = Payload(42, "hello")
18-
val encoded =
19-
formatNoChecksum.encodeToString(Payload.serializer(), value)
20-
val decoded =
21-
formatNoChecksum.decodeFromString(Payload.serializer(), encoded)
20+
val encoded = formatBare.encodeToString(Payload.serializer(), value)
21+
val decoded = formatBare.decodeFromString(Payload.serializer(), encoded)
2222
assertEquals(value, decoded)
2323
}
2424

2525
@Test
2626
fun `roundtrip with checksum`() {
2727
val value = Payload(-5, "world")
28-
val encoded =
29-
formatWithChecksum.encodeToString(Payload.serializer(), value)
30-
val decoded =
31-
formatWithChecksum.decodeFromString(Payload.serializer(), encoded)
28+
val encoded = formatChecksum.encodeToString(Payload.serializer(), value)
29+
val decoded = formatChecksum.decodeFromString(Payload.serializer(), encoded)
3230
assertEquals(value, decoded)
3331
}
3432

3533
@Test
3634
fun `checksum mismatch throws`() {
3735
val value = Payload(7, "x")
38-
val encoded =
39-
formatWithChecksum.encodeToString(Payload.serializer(), value)
36+
val encoded = formatChecksum.encodeToString(Payload.serializer(), value)
4037
val tampered = encoded.dropLast(1) + when (encoded.last()) {
4138
'A' -> 'B'
4239
else -> 'A'
4340
}
4441
assertFailsWith<IllegalArgumentException> {
45-
formatWithChecksum.decodeFromString(Payload.serializer(), tampered)
42+
formatChecksum.decodeFromString(Payload.serializer(), tampered)
4643
}
4744
}
4845

4946
@Test
50-
fun `compactZeros roundtrip without checksum`() {
47+
fun `compactZeros roundtrip`() {
5148
val value = Payload(1, "hi")
52-
val encoded =
53-
formatNoChecksum.encodeToString(Payload.serializer(), value)
54-
val decoded =
55-
formatNoChecksum.decodeFromString(Payload.serializer(), encoded)
49+
val encoded = formatCompact.encodeToString(Payload.serializer(), value)
50+
val decoded = formatCompact.decodeFromString(Payload.serializer(), encoded)
5651
assertEquals(value, decoded)
57-
val uncompressed = EncodedFormat(Base62, compactZeros = false)
58-
.encodeToString(Payload.serializer(), value)
52+
val uncompressed = formatBare.encodeToString(Payload.serializer(), value)
5953
assertTrue(
6054
encoded.length <= uncompressed.length,
6155
"compactZeros ($encoded) should be <= uncompressed ($uncompressed)"
@@ -65,10 +59,8 @@ class EncodedFormatTest {
6559
@Test
6660
fun `compactZeros roundtrip with checksum`() {
6761
val value = Payload(0, "zero")
68-
val encoded =
69-
formatWithChecksum.encodeToString(Payload.serializer(), value)
70-
val decoded =
71-
formatWithChecksum.decodeFromString(Payload.serializer(), encoded)
62+
val encoded = formatCompactChecksum.encodeToString(Payload.serializer(), value)
63+
val decoded = formatCompactChecksum.decodeFromString(Payload.serializer(), encoded)
7264
assertEquals(value, decoded)
7365
}
7466

@@ -78,25 +70,32 @@ class EncodedFormatTest {
7870
data class Z(val a: Int, val b: Int)
7971

8072
val value = Z(0, 0)
81-
val encoded = formatNoChecksum.encodeToString(Z.serializer(), value)
82-
val decoded = formatNoChecksum.decodeFromString(Z.serializer(), encoded)
73+
val encoded = formatCompact.encodeToString(Z.serializer(), value)
74+
val decoded = formatCompact.decodeFromString(Z.serializer(), encoded)
8375
assertEquals(value, decoded)
8476
}
8577

8678
@Test
8779
fun `compactZeros checksum mismatch throws`() {
8880
val value = Payload(7, "x")
89-
val encoded =
90-
formatWithChecksum.encodeToString(Payload.serializer(), value)
81+
val encoded = formatCompactChecksum.encodeToString(Payload.serializer(), value)
9182
val tampered = encoded.dropLast(1) + when (encoded.last()) {
9283
'A' -> 'B'
9384
else -> 'A'
9485
}
9586
assertFailsWith<IllegalArgumentException> {
96-
formatWithChecksum.decodeFromString(Payload.serializer(), tampered)
87+
formatCompactChecksum.decodeFromString(Payload.serializer(), tampered)
9788
}
9889
}
9990

91+
@Test
92+
fun `compactZeros no overhead when no leading zeros`() {
93+
val value = Payload(-1, "a")
94+
val compact = formatCompact.encodeToString(Payload.serializer(), value)
95+
val bare = formatBare.encodeToString(Payload.serializer(), value)
96+
assertEquals(bare, compact)
97+
}
98+
10099
@Test
101100
fun `builder configures custom properties successfully`() {
102101
val value = Payload(99, "builder validation")
@@ -110,14 +109,25 @@ class EncodedFormatTest {
110109
val encoded = customFormat.encodeToString(Payload.serializer(), value)
111110

112111
assertTrue(encoded.isNotEmpty())
113-
val decoded =
114-
customFormat.decodeFromString(Payload.serializer(), encoded)
112+
val decoded = customFormat.decodeFromString(Payload.serializer(), encoded)
115113
assertEquals(value, decoded)
116114

117-
val tampered =
118-
encoded.dropLast(1) + if (encoded.last() == 'u') "t" else "u"
115+
val tampered = encoded.dropLast(1) + if (encoded.last() == 'u') "t" else "u"
119116
assertFailsWith<IllegalArgumentException> {
120117
customFormat.decodeFromString(Payload.serializer(), tampered)
121118
}
122119
}
120+
121+
@Test
122+
fun `builder then composition roundtrip`() {
123+
val value = Payload(0, "composed")
124+
125+
val format = EncodedFormat {
126+
transform = CompactZeros.then(Crc16.asTransform())
127+
}
128+
129+
val encoded = format.encodeToString(Payload.serializer(), value)
130+
val decoded = format.decodeFromString(Payload.serializer(), encoded)
131+
assertEquals(value, decoded)
132+
}
123133
}

0 commit comments

Comments
 (0)