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
43 changes: 34 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
name: CI
name: Run CI

on:
push:
branches: [main]
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build:
lint:
name: Scalafmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: coursier/cache-action@v6
- uses: VirtusLab/scala-cli-setup@v1.14.0
- uses: actions/checkout@v6
- name: Setup coursier cache
uses: coursier/cache-action@v8.1
- uses: VirtusLab/scala-cli-setup@v1
- name: Check formatting
run: scala-cli --power fmt --check .

test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup coursier cache
uses: coursier/cache-action@v8.1
- uses: VirtusLab/scala-cli-setup@v1
- name: Run tests
# Coverage is recorded only for production sources; test sources are
# excluded so they don't inflate the reported number.
run: scala-cli --power test . -O -coverage-out:./.scoverage -O '-coverage-exclude-files:.*test/.*'
- name: Generate coverage
run: scala-cli .scoverage/report.sc
- uses: codecov/codecov-action@v7
with:
scala-cli-version: 1.14.0
- run: scala-cli --power compile .
- run: scala-cli --power test .
fail_ci_if_error: true
files: .scoverage/report/cobertura.xml
token: ${{ secrets.CODECOV_TOKEN }}
- name: Test documentation
run: scala-cli --power doc .
52 changes: 52 additions & 0 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Deploy documentation to Pages

on:
push:
tags:
- 'v*'
branches: [ "main" ]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: true

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup coursier cache
uses: coursier/cache-action@v8.1
- name: Setup scala-cli
uses: VirtusLab/scala-cli-setup@v1
- name: Generate documentation
run: |
scala-cli --power doc . -- \
-project "mcodec" \
-project-version ${{ github.ref_name }} \
-project-footer "made with ❤️ and coffee" \
-social-links:github::https://github.com/halotukozak/mcodec \
-snippet-compiler:compile \
-source-links:"src=github://halotukozak/mcodec?tag=${{ github.ref_name }}" \
-revision:${{ github.ref_name }}
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: 'scala-doc'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
29 changes: 29 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Publish
on:
push:
tags:
- 'v*'
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup coursier cache
uses: coursier/cache-action@v8.1
- uses: VirtusLab/scala-cli-setup@v1
- name: Publish
env:
SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
PGP_PRIVATE_KEY: ${{ secrets.PGP_PRIVATE_KEY }}
PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
run: |
scala-cli --power publish . --verbose \
--user "env:SONATYPE_USERNAME" \
--password "env:SONATYPE_PASSWORD" \
--secret-key "env:PGP_PRIVATE_KEY" \
--secret-key-password "env:PGP_PASSPHRASE"
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@
# scala-cli build artifacts
.scala-build/
.bsp/
scala-doc

# coverage (keep the report generator script)
.scoverage/*
!.scoverage/report.sc
36 changes: 36 additions & 0 deletions .scoverage/report.sc
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//> using scala 3.8.4
//> using dep org.scoverage::scalac-scoverage-reporter:2.5.2
//> using dep org.scoverage::scalac-scoverage-domain:2.5.2
//> using dep org.scoverage::scalac-scoverage-serializer:2.5.2

import scoverage.reporter.ScoverageHtmlWriter
import scoverage.reporter.CoberturaXmlWriter
import scoverage.reporter.IOUtils
import java.io.File
import scoverage.serialize.Serializer

val coverageFile = new File(".scoverage/scoverage.coverage")
val sourceDir = new File(".")
val measurementDir = new File(".scoverage")
val outDir = new File(".scoverage/report")

if !coverageFile.exists() then
println(s"Error: Coverage file not found at ${coverageFile.getAbsolutePath}")
sys.exit(1)

val coverage = Serializer.deserialize(coverageFile, sourceDir)

val measurementFiles = IOUtils.findMeasurementFiles(measurementDir)

val measurements = IOUtils.invoked(measurementFiles.toIndexedSeq)
coverage.apply(measurements)

outDir.mkdirs()

val htmlReporter = new ScoverageHtmlWriter(Seq(sourceDir), outDir, None)
htmlReporter.write(coverage)

val xmlReporter = new CoberturaXmlWriter(Seq(sourceDir), outDir, None)
xmlReporter.write(coverage)

println(s"Statement coverage: ${coverage.statementCoverageFormatted}%")
94 changes: 90 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,100 @@
# mcodec

GenCodec-style serialization/deserialization for Scala 3, with derivation built on
[Made](https://github.com/halotukozak/made). Format-agnostic streaming `Input`/`Output`
core; JSON backend; JVM.
**GenCodec-style serialization for Scala 3** — a format-agnostic, streaming `Input`/`Output` core with type class
derivation built on [M&DE](https://github.com/halotukozak/made). Ships with a JSON backend.

> **Experimental.** Pinned to Scala **3.8.4** (Scala Next), required by Made (`made_3:0.1.2`).
> **Experimental.** Pinned to Scala **3.8.4** (Scala Next), required by Made (`made_3:0.1.2`). JVM only.

## Overview

mcodec is a serialization library in the spirit of [AVSystem GenCodec](https://github.com/AVSystem/scala-commons): a
single `MCodec[T]` type class both reads and writes, and the wire format is decoupled from the codecs. Codecs talk to
an abstract streaming `Input`/`Output`, so the same `MCodec[T]` works across any backend that implements those.

- **One type class for read and write** — `MCodec[T]` with `read(input)` / `write(output, value)`
- **Format-agnostic core** — codecs target a streaming `Input`/`Output`; the JSON backend is just one implementation
- **Automatic derivation** — `derives MCodec` for case classes, enums, and sealed hierarchies, powered by M&DE mirrors
- **Annotation-aware** — `@name` to rename fields and ADT cases on the wire, `@flatten` / `@defaultCase` for ADTs
- **Combinators** — `transform`, `transformed`, `nullable`, and `makeLazy` for building codecs from existing ones
- **Explicit nulls** — compiled with `-Yexplicit-nulls`; nullability is expressed in the types

Built-in codecs cover the primitives, `BigInt`/`BigDecimal`, `UUID`, `Option`, `Either`, tuples, and the common
collections (`List`, `Vector`, `Seq`, `Set`, `Map`).

## Installation

> mcodec has not had its first release yet. The coordinates below are how it will be published to Maven Central under
> `io.github.halotukozak` once tagged. Until then, build from source (see [Build](#build)).

### scala-cli

```scala
//> using scala 3.8.4
//> using dep io.github.halotukozak::mcodec::<version>
```

### sbt

```scala
scalaVersion := "3.8.4"
libraryDependencies += "io.github.halotukozak" %% "mcodec" % "<version>"
```

### mill

```scala
def scalaVersion = "3.8.4"
def mvDeps = Seq(mvn"io.github.halotukozak::mcodec::<version>")
```

## Quickstart

Derive an `MCodec` for a case class and round-trip it through JSON.

```scala
import mcodec.*

case class User(name: String, age: Int) derives MCodec

val json = Json.write(User("Alice", 30)) // {"name":"Alice","age":30}
val user = Json.read[User](json) // User("Alice", 30)
```

Rename fields and ADT cases on the wire with `@name`:

```scala
import mcodec.*
import made.annotation.name

case class User(@name("user_name") name: String, age: Int) derives MCodec

enum Shape derives MCodec:
@name("circ") case Circle(radius: Double)

Json.write(User("bob", 30)) // {"user_name":"bob","age":30}
Json.write[Shape](Shape.Circle(2.0)) // {"circ":{"radius":2.0}}
```

Build codecs from existing ones with the combinators on `MCodec`:

```scala
import mcodec.*

opaque type Email = String
given MCodec[Email] = MCodec[String].transform(identity, identity)

val nullableInt: MCodec[Int | Null] = MCodec[Int].nullable
```

## Build

```sh
scala-cli --power compile .
scala-cli --power test .
scala-cli --power fmt .
```

## Acknowledgements

mcodec is inspired by the [**AVSystem commons**](https://github.com/AVSystem/scala-commons) by [**ghik
**](https://github.com/ghik), whose `GenCodec` is the model for the codec design