Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -115,6 +117,33 @@ tasks.withType<Test>().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.
Expand Down
17 changes: 17 additions & 0 deletions config/detekt/baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>FunctionOnlyReturningConstant:ZstdEncoder.kt$PureZstdEncoder$private fun frameHeaderDescriptor(): Byte</ID>
<ID>LoopWithTooManyJumpStatements:ZstdEncoder.kt$PureZstdEncoder$while</ID>
<ID>MatchingDeclarationName:BitWriter.kt$ReverseBitWriter</ID>
<ID>MatchingDeclarationName:FseEncoder.kt$FseEncTable</ID>
<ID>MatchingDeclarationName:Huffman.kt$HuffmanTable</ID>
<ID>MatchingDeclarationName:ZstdDecoder.kt$PureZstdDecoder</ID>
<ID>MatchingDeclarationName:ZstdEncoder.kt$PureZstdEncoder</ID>
<ID>ThrowsCount:Huffman.kt$HuffmanTable.Companion$fun fromWeights(weights: IntArray, numSymbols: Int): HuffmanTable</ID>
<ID>ThrowsCount:ZstdDecoder.kt$PureZstdDecoder$private fun decodeSequences( reader: ForwardByteReader, blockEnd: Int, literals: ByteArray, out: OutputBuffer, state: DecodeState, )</ID>
<ID>UnusedParameter:ZstdDecoder.kt$PureZstdDecoder$blockEnd: Int</ID>
<ID>UnusedPrivateProperty:OutputBuffer.kt$OutputBuffer$i</ID>
</CurrentIssues>
</SmellBaseline>
40 changes: 40 additions & 0 deletions config/detekt/detekt.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
18 changes: 8 additions & 10 deletions src/commonMain/kotlin/org/meshtastic/kzstd/Zstd.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/commonMain/kotlin/org/meshtastic/kzstd/internal/Fse.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ internal class FseTable(
highThreshold--
symbolNext[s] = 1
}

else -> symbolNext[s] = normalizedCounts[s]
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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] }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
Loading