diff --git a/.editorconfig b/.editorconfig index 581d5b4..85f876c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -20,6 +20,13 @@ ktlint_standard_filename = disabled ktlint_standard_no-wildcard-imports = enabled ktlint_code_style = intellij_idea +# The committed trained-dictionary / test-vector fixtures are chunked Base64 and +# hex string constants that intentionally exceed max_line_length; wrapping them +# would obscure the fixture. Turn the line-length rule off for that file only. +[**/TestVectors.kt] +max_line_length = off +ktlint_standard_max-line-length = disabled + [*.{yml,yaml,json,toml}] indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca93f56..518a4a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,44 @@ env: GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} jobs: + # Code-quality gate + the targets whose tests actually EXECUTE on a Linux host: + # JVM, JS (Node + headless-Chrome), Wasm (wasmJs/wasmWasi on Node) and the + # linuxX64 Kotlin/Native binary. The macOS job below cross-compiles these but + # can't run the linuxX64 test binary; running them here closes that gap cheaply. + linux: + name: Quality gate & Linux tests (JVM/JS/Wasm/linuxX64) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Cache Konan (Kotlin/Native toolchain) + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + konan-${{ runner.os }}- + + # Formatting (Spotless/ktlint) + static analysis (detekt) + the ABI check, + # then the tests that run natively on Linux. linuxX64Test builds and runs the + # Kotlin/Native test binary that the macOS host can only cross-compile. + - name: Quality gate & Linux/JVM/JS/Wasm tests + run: >- + ./gradlew + spotlessCheck detekt apiCheck + jvmTest jsTest wasmJsTest wasmWasiTest linuxX64Test + --stacktrace + build: name: Build, test & API check (all KMP targets) # macOS is required to compile the Apple/native klibs (iOS, macOS, tvOS) — the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7dea250..ab5330a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,11 +40,17 @@ signed off. To fix retroactively: `git rebase --signoff HEAD~N && git push --for | JVM tests (incl. the zstd-jni interop oracle) | `./gradlew jvmTest` | | API surface check | `./gradlew apiCheck` | | API surface dump (after an intended change) | `./gradlew apiDump` | +| Reformat Kotlin (Spotless/ktlint) | `./gradlew spotlessApply` | +| Formatting + static-analysis gate | `./gradlew spotlessCheck detekt` | | Regenerate the trained test dictionary | `python3 scripts/train_test_dict.py` | -Static formatting/analysis (spotless + detekt, as used by larger meshtastic KMP -SDKs) is not yet wired into the build — `.editorconfig` documents the intended -Kotlin style in the meantime. Wiring it in is a welcome contribution. +Formatting (Spotless/ktlint) and static analysis (detekt) are wired into the build +and gated in CI. ktlint reads `.editorconfig`, so that file remains the single +source of Kotlin style. Run `./gradlew spotlessApply` to auto-format before +committing. Pre-existing findings in the RFC 8878 engine (lifted verbatim from +TAKPacket-SDK) are recorded in `config/detekt/baseline.xml`; the gate blocks *new* +issues. Regenerate the baseline after intentionally clearing engine findings with +`./gradlew detektBaseline`. ## Public API changes diff --git a/build.gradle.kts b/build.gradle.kts index c764467..93167c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,8 @@ plugins { alias(libs.plugins.dokka) alias(libs.plugins.binary.compat) alias(libs.plugins.kover) + alias(libs.plugins.spotless) + alias(libs.plugins.detekt) } // GROUP / VERSION_NAME come from gradle.properties — the single coordinate source @@ -115,6 +117,33 @@ tasks.withType().configureEach { useJUnitPlatform() } +// ───────────────────────────────────────────────────────────────────────────── +// Code quality: Spotless (ktlint formatting) + detekt (static analysis). +// ktlint honors the repo `.editorconfig`, so the two stay in one style source of +// truth. `spotlessApply` reformats; `spotlessCheck` + `detekt` gate CI. +spotless { + kotlin { + target("src/**/*.kt") + ktlint(libs.versions.ktlint.get()) + } + kotlinGradle { + target("*.gradle.kts") + ktlint(libs.versions.ktlint.get()) + } +} + +detekt { + buildUponDefaultConfig = true + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + // Pre-existing findings in the RFC 8878 engine (lifted verbatim from + // TAKPacket-SDK) are captured in a baseline so the gate blocks NEW issues + // while the engine is cleaned up incrementally. Regenerate: ./gradlew detektBaseline + baseline = file("$rootDir/config/detekt/baseline.xml") + // KMP has no `main`/`test` source sets, so point detekt at the source tree + // directly (it recurses for *.kt/*.kts). + source.setFrom(files("src")) +} + // ───────────────────────────────────────────────────────────────────────────── // Maven Central publishing via the Vanniktech maven-publish plugin. // Coordinates are read from gradle.properties: GROUP, POM_ARTIFACT_ID, VERSION_NAME. diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml new file mode 100644 index 0000000..1c3ee58 --- /dev/null +++ b/config/detekt/baseline.xml @@ -0,0 +1,17 @@ + + + + + FunctionOnlyReturningConstant:ZstdEncoder.kt$PureZstdEncoder$private fun frameHeaderDescriptor(): Byte + LoopWithTooManyJumpStatements:ZstdEncoder.kt$PureZstdEncoder$while + MatchingDeclarationName:BitWriter.kt$ReverseBitWriter + MatchingDeclarationName:FseEncoder.kt$FseEncTable + MatchingDeclarationName:Huffman.kt$HuffmanTable + MatchingDeclarationName:ZstdDecoder.kt$PureZstdDecoder + MatchingDeclarationName:ZstdEncoder.kt$PureZstdEncoder + ThrowsCount:Huffman.kt$HuffmanTable.Companion$fun fromWeights(weights: IntArray, numSymbols: Int): HuffmanTable + ThrowsCount:ZstdDecoder.kt$PureZstdDecoder$private fun decodeSequences( reader: ForwardByteReader, blockEnd: Int, literals: ByteArray, out: OutputBuffer, state: DecodeState, ) + UnusedParameter:ZstdDecoder.kt$PureZstdDecoder$blockEnd: Int + UnusedPrivateProperty:OutputBuffer.kt$OutputBuffer$i + + diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..6147392 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,40 @@ +# detekt configuration for kzstd. Layered on top of detekt's default rules +# (build.gradle.kts sets buildUponDefaultConfig = true), so this file only +# records the deliberate deviations. +# +# Formatting is owned by Spotless/ktlint (which reads .editorconfig), so the +# detekt `formatting` ruleset and line-length style checks are left to ktlint to +# avoid two tools fighting over the same concern. + +style: + # Line length is enforced by ktlint via .editorconfig (max_line_length = 120, + # with a per-file override for the Base64/hex fixtures in TestVectors.kt). + MaxLineLength: + active: false + # The codec engine uses explicit magic numbers from RFC 8878 (FSE/Huffman + # tables, frame headers); flagging every one of them is pure noise. + MagicNumber: + active: false + ReturnCount: + active: false + +complexity: + # The RFC 8878 encoder/decoder are inherently long, branchy state machines + # extracted verbatim from the reference implementation; the default thresholds + # are tuned for application code, not codec internals. + LongMethod: + active: false + CyclomaticComplexMethod: + active: false + NestedBlockDepth: + active: false + LongParameterList: + active: false + ComplexCondition: + active: false + TooManyFunctions: + active: false + +exceptions: + TooGenericExceptionCaught: + active: false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72c3110..8f21d19 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,9 @@ kover = "0.9.9" vanniktech-publish = "0.37.0" junit-jupiter = "6.1.2" junit-platform = "6.1.2" +spotless = "8.8.0" +ktlint = "1.8.0" +detekt = "1.23.8" [libraries] zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" } @@ -22,3 +25,5 @@ vanniktech-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanni dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } binary-compat = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compat" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/Zstd.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/Zstd.kt index 42ecc5a..e36d681 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/Zstd.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/Zstd.kt @@ -56,20 +56,18 @@ public object Zstd { /** Decompress a standard zstd [frame] with no dictionary. */ @Throws(ZstdException::class) - public fun decompress(frame: ByteArray, maxSize: Int): ByteArray = - decompress(frame, ZstdDictionary.EMPTY, maxSize) + public fun decompress(frame: ByteArray, maxSize: Int): ByteArray = decompress(frame, ZstdDictionary.EMPTY, maxSize) /** * Run [body], letting a [ZstdException] propagate and wrapping any other * (non-[Error]) failure in a [ZstdException] so callers catch one type. * `Error` (e.g. `OutOfMemoryError`) is intentionally NOT caught. */ - private inline fun wrapFailures(op: String, size: Int, body: () -> ByteArray): ByteArray = - try { - body() - } catch (e: ZstdException) { - throw e - } catch (e: Exception) { - throw ZstdException("zstd $op failed (size=$size): ${e.message ?: e::class.simpleName ?: "unknown"}", e) - } + private inline fun wrapFailures(op: String, size: Int, body: () -> ByteArray): ByteArray = try { + body() + } catch (e: ZstdException) { + throw e + } catch (e: Exception) { + throw ZstdException("zstd $op failed (size=$size): ${e.message ?: e::class.simpleName ?: "unknown"}", e) + } } diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/ZstdDictionary.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/ZstdDictionary.kt index b0ee2f9..70037d8 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/ZstdDictionary.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/ZstdDictionary.kt @@ -21,7 +21,9 @@ import org.meshtastic.kzstd.internal.ParsedDictionary * immutable after construction and safe to share across threads — concurrent * compress/decompress calls with the same instance need no synchronization. */ -public class ZstdDictionary @Throws(ZstdException::class) constructor(bytes: ByteArray) { +public class ZstdDictionary +@Throws(ZstdException::class) +constructor(bytes: ByteArray) { internal val parsed: ParsedDictionary internal val matchIndex: MatchIndex diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/BitReader.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/BitReader.kt index b6314ac..3966a90 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/BitReader.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/BitReader.kt @@ -13,11 +13,7 @@ import org.meshtastic.kzstd.ZstdException * This is pure Kotlin/common — no `java.*`, no `expect/actual` — so the whole * decoder can later serve as the wasmWasi `decompress` actual. */ -internal class ForwardByteReader( - val backingArray: ByteArray, - var pos: Int, - private val end: Int, -) { +internal class ForwardByteReader(val backingArray: ByteArray, var pos: Int, private val end: Int) { val remaining: Int get() = end - pos val endPos: Int get() = end @@ -104,15 +100,27 @@ internal class ReverseBitReader(private val buf: ByteArray, private val start: I private fun leadingZeros8(v: Int): Int { var n = 0 var x = v and 0xFF - if (x and 0xF0 == 0) { n += 4; x = x shl 4 } - if (x and 0xC0 == 0) { n += 2; x = x shl 2 } - if (x and 0x80 == 0) { n += 1; x = x shl 1 } + if (x and 0xF0 == 0) { + n += 4 + x = x shl 4 + } + if (x and 0xC0 == 0) { + n += 2 + x = x shl 2 + } + if (x and 0x80 == 0) { + n += 1 + x = x shl 1 + } return n } /** Read the single bit at global index [g], or 0 (with overflow) if g < 0. */ private fun bitAt(g: Int): Int { - if (g < 0) { overflowed = true; return 0 } + if (g < 0) { + overflowed = true + return 0 + } val byteIndex = start + (g ushr 3) val bitInByte = g and 7 return (buf[byteIndex].toInt() ushr bitInByte) and 1 diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Fse.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Fse.kt index 082d875..61028e8 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Fse.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Fse.kt @@ -59,6 +59,7 @@ internal class FseTable( highThreshold-- symbolNext[s] = 1 } + else -> symbolNext[s] = normalizedCounts[s] } } @@ -110,7 +111,10 @@ internal class FseTable( private fun highBit(v: Int): Int { var n = 0 var x = v - while (x > 1) { x = x shr 1; n++ } + while (x > 1) { + x = x shr 1 + n++ + } return n } } @@ -156,11 +160,7 @@ internal class FseState(private val table: FseTable) { * remaining probability budget, with a run-length escape for streaks of zero * counts. */ -internal fun parseFseTable( - reader: ForwardByteReader, - maxLog: Int, - maxSymbol: Int, -): FseTable { +internal fun parseFseTable(reader: ForwardByteReader, maxLog: Int, maxSymbol: Int): FseTable { val bits = ForwardBitReader(reader) // This mirrors the reference `FSE_readNCount`. `remaining` tracks the diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Huffman.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Huffman.kt index 7d41189..6eab5e5 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Huffman.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/Huffman.kt @@ -113,7 +113,10 @@ internal class HuffmanTable( private fun highBit(v: Int): Int { var n = 0 var x = v - while (x > 1) { x = x shr 1; n++ } + while (x > 1) { + x = x shr 1 + n++ + } return n } } @@ -226,7 +229,9 @@ internal fun decodeWeightStream(fseTable: FseTable, br: ReverseBitReader): IntAr // transition to advance. (An earlier guard threw on any non-advancing // transition; that was a false positive — it rejected valid trained // dictionaries whose weight FSE table contains 0-bit states.) - val t = current; current = other; other = t + val t = current + current = other + other = t } return IntArray(collected.size) { collected[it] } } diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/MatchIndex.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/MatchIndex.kt index 56a2ebf..629f3de 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/MatchIndex.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/MatchIndex.kt @@ -59,8 +59,7 @@ internal class MatchIndex private constructor( internal const val MAX_CANDIDATES = 32 @PublishedApi - internal fun hash(v: Int): Int = - ((v * -1640531527) ushr (32 - HASH_LOG)) and (HASH_SIZE - 1) + internal fun hash(v: Int): Int = ((v * -1640531527) ushr (32 - HASH_LOG)) and (HASH_SIZE - 1) /** Build a hash-chain index over [content]. Called once per dictionary. */ internal fun build(content: ByteArray): MatchIndex { diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/OutputBuffer.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/OutputBuffer.kt index d9148a7..176b29c 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/OutputBuffer.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/OutputBuffer.kt @@ -25,6 +25,7 @@ import org.meshtastic.kzstd.ZstdException internal class OutputBuffer(private val dict: ByteArray, private val maxSize: Int) { private val dictLen = dict.size + // Holds ONLY the frame's own output bytes (no dict prefix). `size` is the // virtual position in the `[dict][output]` history; frame bytes occupy // indices [0, frameLen) of `out`. @@ -63,8 +64,7 @@ internal class OutputBuffer(private val dict: ByteArray, private val maxSize: In * Resolve a byte at virtual history position [pos] (0 = first dict byte) from * either the dictionary prefix or the frame output. */ - private fun byteAt(pos: Int): Byte = - if (pos < dictLen) dict[pos] else out[pos - dictLen] + private fun byteAt(pos: Int): Byte = if (pos < dictLen) dict[pos] else out[pos - dictLen] /** * Copy a match of [length] bytes from [offset] bytes before the current diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ParsedDictionary.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ParsedDictionary.kt index 8246913..9043d1b 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ParsedDictionary.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ParsedDictionary.kt @@ -64,7 +64,8 @@ internal class ParsedDictionary private constructor( val huffman = parseHuffmanTable(reader) val offsetFse = parseFseTable(reader, maxLog = OFFSET_MAX_LOG, maxSymbol = OFFSET_MAX_SYMBOL) val matchFse = parseFseTable(reader, maxLog = MATCH_LENGTH_MAX_LOG, maxSymbol = MATCH_LENGTH_MAX_SYMBOL) - val litLenFse = parseFseTable(reader, maxLog = LITERAL_LENGTH_MAX_LOG, maxSymbol = LITERAL_LENGTH_MAX_SYMBOL) + val litLenFse = + parseFseTable(reader, maxLog = LITERAL_LENGTH_MAX_LOG, maxSymbol = LITERAL_LENGTH_MAX_SYMBOL) // Three 4-byte little-endian repeat offsets. val rep = IntArray(3) @@ -83,10 +84,9 @@ internal class ParsedDictionary private constructor( ) } - private fun leInt(b: ByteArray, off: Int): Int = - (b[off].toInt() and 0xFF) or - ((b[off + 1].toInt() and 0xFF) shl 8) or - ((b[off + 2].toInt() and 0xFF) shl 16) or - ((b[off + 3].toInt() and 0xFF) shl 24) + private fun leInt(b: ByteArray, off: Int): Int = (b[off].toInt() and 0xFF) or + ((b[off + 1].toInt() and 0xFF) shl 8) or + ((b[off + 2].toInt() and 0xFF) shl 16) or + ((b[off + 3].toInt() and 0xFF) shl 24) } } diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdDecoder.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdDecoder.kt index e704f9c..5342c4a 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdDecoder.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdDecoder.kt @@ -70,13 +70,16 @@ internal object PureZstdDecoder { 0 -> { // Raw_Block: literal bytes copied verbatim. for (i in 0 until blockSize) out.appendByte(reader.readByte()) } + 1 -> { // RLE_Block: one byte repeated blockSize times. val b = reader.readByte() for (i in 0 until blockSize) out.appendByte(b) } + 2 -> { // Compressed_Block. decodeCompressedBlock(reader, blockSize, out, state, maxSize) } + else -> throw ZstdException("reserved block type $blockType") } @@ -102,7 +105,10 @@ internal object PureZstdDecoder { // Dictionary_ID field, 0/1/2/4 bytes. val dictIdBytes = when (dictIdFlag) { - 0 -> 0; 1 -> 1; 2 -> 2; else -> 4 + 0 -> 0 + 1 -> 1 + 2 -> 2 + else -> 4 } if (dictIdBytes > 0) reader.readLEInt(dictIdBytes) @@ -168,8 +174,12 @@ internal object PureZstdDecoder { ): ByteArray { // Regenerated_Size width depends on size_format. val regenSize: Int = when (sizeFormat) { - 0, 2 -> first ushr 3 // 5-bit size, low form (sizeFormat bit0 == 0) - 1 -> (first ushr 4) or (reader.readByte() shl 4) // 12-bit + 0, 2 -> first ushr 3 + + // 5-bit size, low form (sizeFormat bit0 == 0) + 1 -> (first ushr 4) or (reader.readByte() shl 4) + + // 12-bit else -> (first ushr 4) or (reader.readByte() shl 4) or (reader.readByte() shl 12) // 20-bit } // A 20-bit Regenerated_Size can request ~1 MB; reject before allocating @@ -207,6 +217,7 @@ internal object PureZstdDecoder { compressedSize = (b1 ushr 6) or (b2 shl 2) fourStreams = false } + 1 -> { // 4 streams, 10-bit sizes val b1 = reader.readByte() val b2 = reader.readByte() @@ -214,6 +225,7 @@ internal object PureZstdDecoder { compressedSize = (b1 ushr 6) or (b2 shl 2) fourStreams = true } + 2 -> { // 4 streams, 14-bit sizes val b1 = reader.readByte() val b2 = reader.readByte() @@ -222,6 +234,7 @@ internal object PureZstdDecoder { compressedSize = (b2 ushr 2) or (b3 shl 6) fourStreams = true } + else -> { // 4 streams, 18-bit sizes val b1 = reader.readByte() val b2 = reader.readByte() @@ -328,8 +341,11 @@ internal object PureZstdDecoder { reader.pos = blockEnd return } + nbSeq < 128 -> { /* value is nbSeq */ } + nbSeq < 255 -> nbSeq = ((nbSeq - 128) shl 8) + reader.readByte() + else -> nbSeq = reader.readByte() + (reader.readByte() shl 8) + 0x7F00 } @@ -424,22 +440,21 @@ internal object PureZstdDecoder { * (parse a table here), 3 = Repeat (reuse the dict's / previous block's * table). The reader is advanced for RLE and FSE_Compressed modes. */ - private fun resolveTable( - reader: ForwardByteReader, - mode: Int, - kind: SeqKind, - state: DecodeState, - ): FseTable = when (mode) { - 0 -> kind.predefined() - 1 -> { - // RLE: a single byte is the only symbol (probability 1.0), tableLog 0. - val symbol = reader.readByte() - rleTable(symbol) + private fun resolveTable(reader: ForwardByteReader, mode: Int, kind: SeqKind, state: DecodeState): FseTable = + when (mode) { + 0 -> kind.predefined() + + 1 -> { + // RLE: a single byte is the only symbol (probability 1.0), tableLog 0. + val symbol = reader.readByte() + rleTable(symbol) + } + + 2 -> parseFseTable(reader, kind.maxLog, kind.maxSymbol) + + else -> kind.repeat(state) + ?: throw ZstdException("repeat mode for ${kind.name} but no prior/dict table") } - 2 -> parseFseTable(reader, kind.maxLog, kind.maxSymbol) - else -> kind.repeat(state) - ?: throw ZstdException("repeat mode for ${kind.name} but no prior/dict table") - } private fun rleTable(symbol: Int): FseTable { // One-state table: always emits `symbol`, consumes 0 bits, stays in @@ -471,28 +486,39 @@ internal object PureZstdDecoder { val repCode = if (literalLength == 0) offsetValue else offsetValue - 1 return when (repCode) { 0 -> rep[0] + 1 -> { val v = rep[1] - rep[1] = rep[0]; rep[0] = v + rep[1] = rep[0] + rep[0] = v v } + 2 -> { val v = rep[2] - rep[2] = rep[1]; rep[1] = rep[0]; rep[0] = v + rep[2] = rep[1] + rep[1] = rep[0] + rep[0] = v v } + else -> { // repCode == 3 only reachable when literalLength == 0 and // offsetValue == 3: actual = rep[0] - 1. val v = rep[0] - 1 - rep[2] = rep[1]; rep[1] = rep[0]; rep[0] = v + rep[2] = rep[1] + rep[1] = rep[0] + rep[0] = v v } } } private enum class SeqKind { - LITERAL_LENGTH, OFFSET, MATCH_LENGTH; + LITERAL_LENGTH, + OFFSET, + MATCH_LENGTH, + ; val maxLog: Int get() = when (this) { LITERAL_LENGTH -> LITERAL_LENGTH_MAX_LOG diff --git a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdEncoder.kt b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdEncoder.kt index f9def34..682eaa5 100644 --- a/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdEncoder.kt +++ b/src/commonMain/kotlin/org/meshtastic/kzstd/internal/ZstdEncoder.kt @@ -258,7 +258,10 @@ internal object PureZstdEncoder { // Insert all covered positions into the chain for future matches. var q = i + 1 val matchEnd = i + len - while (q < matchEnd && q + MIN_MATCH <= n) { insert(q); q++ } + while (q < matchEnd && q + MIN_MATCH <= n) { + insert(q) + q++ + } i = matchEnd litStart = i } else { @@ -313,12 +316,14 @@ internal object PureZstdEncoder { size < 32 -> { out.add(((size shl 3) or (0 shl 2) or 0).toByte()) // [size:5][00][00] } + size < 4096 -> { val b0 = (0) or (0b01 shl 2) or ((size and 0xF) shl 4) val b1 = (size ushr 4) and 0xFF out.add(b0.toByte()) out.add(b1.toByte()) } + else -> { val b0 = (0) or (0b11 shl 2) or ((size and 0xF) shl 4) val b1 = (size ushr 4) and 0xFF @@ -334,12 +339,18 @@ internal object PureZstdEncoder { val nbSeq = sequences.size // Number_of_Sequences. when { - nbSeq == 0 -> { out.add(0); return } + nbSeq == 0 -> { + out.add(0) + return + } + nbSeq < 128 -> out.add(nbSeq.toByte()) + nbSeq < 0x7F00 -> { out.add((((nbSeq ushr 8) + 128) and 0xFF).toByte()) out.add((nbSeq and 0xFF).toByte()) } + else -> { out.add(0xFF.toByte()) val v = nbSeq - 0x7F00 @@ -396,9 +407,12 @@ internal object PureZstdEncoder { /** Per-sequence FSE symbols + extra-bit payloads. */ private class Codes( - val llCode: Int, val llExtra: Int, - val mlCode: Int, val mlExtra: Int, - val ofCode: Int, val ofExtra: Int, + val llCode: Int, + val llExtra: Int, + val mlCode: Int, + val mlExtra: Int, + val ofCode: Int, + val ofExtra: Int, ) private fun writeExtra(bw: ReverseBitWriter, c: Codes) { @@ -438,7 +452,10 @@ internal object PureZstdEncoder { private fun highBit(v: Int): Int { var n = 0 var x = v - while (x > 1) { x = x shr 1; n++ } + while (x > 1) { + x = x shr 1 + n++ + } return n } diff --git a/src/commonTest/kotlin/org/meshtastic/kzstd/TestVectors.kt b/src/commonTest/kotlin/org/meshtastic/kzstd/TestVectors.kt index b099dc8..4df822d 100644 --- a/src/commonTest/kotlin/org/meshtastic/kzstd/TestVectors.kt +++ b/src/commonTest/kotlin/org/meshtastic/kzstd/TestVectors.kt @@ -135,15 +135,17 @@ internal object TestVectors { /** Round-trip corpus: structured records + edge cases. */ val corpus: List = structured + listOf( - ByteArray(0), // empty - "a".encodeToByteArray(), // 1 byte (< MIN_MATCH) - "ab".encodeToByteArray(), // 2 bytes - "abc".encodeToByteArray(), // 3 bytes (== MIN_MATCH) - "abcd".encodeToByteArray(), // 4 bytes - ByteArray(5000) { 'A'.code.toByte() }, // highly repetitive - ("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " + - "tempor incididunt ut labore et dolore magna aliqua. ").repeat(8).encodeToByteArray(), - pseudoRandom(2000), // near-incompressible (Raw_Block path) + ByteArray(0), // empty + "a".encodeToByteArray(), // 1 byte (< MIN_MATCH) + "ab".encodeToByteArray(), // 2 bytes + "abc".encodeToByteArray(), // 3 bytes (== MIN_MATCH) + "abcd".encodeToByteArray(), // 4 bytes + ByteArray(5000) { 'A'.code.toByte() }, // highly repetitive + ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " + + "tempor incididunt ut labore et dolore magna aliqua. " + ).repeat(8).encodeToByteArray(), + pseudoRandom(2000), // near-incompressible (Raw_Block path) ) /** @@ -162,8 +164,7 @@ internal object TestVectors { "7b2274797065223a22686561727462656174222c22736571223a3634383838362c226e6f6465223a226e6f64652d3233222c227374617465223a226f6b222c226c6174223a2d36382e32303131302c226c6f6e223a2d32352e36383235352c226d7367223a22726567696f6e20616e64206e6f6d696e616c206e6f6d696e616c206d6f6e69746f7265642074686520746865227d", ) - private fun hexToBytes(s: String): ByteArray = - ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() } + private fun hexToBytes(s: String): ByteArray = ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() } /** Deterministic pseudo-random bytes (a 32-bit LCG) — incompressible-ish input. */ private fun pseudoRandom(n: Int): ByteArray { diff --git a/src/jvmTest/kotlin/org/meshtastic/kzstd/KzstdLibzstdInteropTest.kt b/src/jvmTest/kotlin/org/meshtastic/kzstd/KzstdLibzstdInteropTest.kt index 6758a3b..0259630 100644 --- a/src/jvmTest/kotlin/org/meshtastic/kzstd/KzstdLibzstdInteropTest.kt +++ b/src/jvmTest/kotlin/org/meshtastic/kzstd/KzstdLibzstdInteropTest.kt @@ -1,12 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.meshtastic.kzstd -import com.github.luben.zstd.Zstd as LibZstd import com.github.luben.zstd.ZstdDictCompress import com.github.luben.zstd.ZstdDictDecompress import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertTrue +import com.github.luben.zstd.Zstd as LibZstd /** * The both-directions interop oracle (R6): kzstd's pure-Kotlin frames must decode @@ -73,7 +73,10 @@ class KzstdLibzstdInteropTest { // treeless frame — and that the committed cross-target fixture is itself treeless. val cdict = ZstdDictCompress(dictBytes, Zstd.DEFAULT_LEVEL) val treeless = TestVectors.structured.count { firstBlockLitType(LibZstd.compress(it, cdict)) == 3 } - assertTrue(treeless > 0, "libzstd emitted no treeless (dict-Huffman) frames — dict-entropy decode path untested") + assertTrue( + treeless > 0, + "libzstd emitted no treeless (dict-Huffman) frames — dict-entropy decode path untested", + ) assertTrue( firstBlockLitType(TestVectors.treelessDictFrame) == 3, "the committed treelessDictFrame is not actually treeless", @@ -83,13 +86,24 @@ class KzstdLibzstdInteropTest { /** Literals_Block_Type of the first block (RFC 8878), or -1 if not a Compressed block. */ private fun firstBlockLitType(frame: ByteArray): Int { var p = 4 // skip frame magic - val fhd = frame[p].toInt() and 0xFF; p++ + val fhd = frame[p].toInt() and 0xFF + p++ val fcsFlag = (fhd ushr 6) and 0x3 val singleSegment = (fhd ushr 5) and 0x1 val dictIdFlag = fhd and 0x3 if (singleSegment == 0) p++ // window descriptor - p += when (dictIdFlag) { 0 -> 0; 1 -> 1; 2 -> 2; else -> 4 } - p += when (fcsFlag) { 0 -> if (singleSegment == 1) 1 else 0; 1 -> 2; 2 -> 4; else -> 8 } + p += when (dictIdFlag) { + 0 -> 0 + 1 -> 1 + 2 -> 2 + else -> 4 + } + p += when (fcsFlag) { + 0 -> if (singleSegment == 1) 1 else 0 + 1 -> 2 + 2 -> 4 + else -> 8 + } val bh = (frame[p].toInt() and 0xFF) or ((frame[p + 1].toInt() and 0xFF) shl 8) or ((frame[p + 2].toInt() and 0xFF) shl 16) val blockType = (bh ushr 1) and 0x3