Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
lihaoyi committed Feb 10, 2024
0 parents commit 89df86d
Show file tree
Hide file tree
Showing 78 changed files with 2,382 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# These are supported funding model platforms

github: lihaoyi
7 changes: 7 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: 2
updates:

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
77 changes: 77 additions & 0 deletions .github/workflows/actions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: ci

on:
push:
branches:
- main
tags:
- '*'
pull_request:
branches:
- main

jobs:

test-jvm:
runs-on: ubuntu-latest
strategy:
matrix:
java: ['8', '17']
env:
JAVA_OPTS: "-Xss10M"
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: ${{ matrix.java }}
- name: Run JVM tests
run: |
./mill -i "unroll[_].tests[_].__.run"
publish-sonatype:
if: github.repository == 'com-lihaoyi/unroll' && contains(github.ref, 'refs/tags/')
needs:
- test-jvm
runs-on: ubuntu-latest
env:
SONATYPE_PGP_PRIVATE_KEY: ${{ secrets.SONATYPE_PGP_PRIVATE_KEY }}
SONATYPE_PGP_PRIVATE_KEY_PASSWORD: ${{ secrets.SONATYPE_PGP_PRIVATE_KEY_PASSWORD }}
SONATYPE_USER: ${{ secrets.SONATYPE_USER }}
SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
LANG: "en_US.UTF-8"
LC_MESSAGES: "en_US.UTF-8"
LC_ALL: "en_US.UTF-8"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 8
- name: Publish to Maven Central
run: |
if [[ $(git tag --points-at HEAD) != '' ]]; then
echo $SONATYPE_PGP_PRIVATE_KEY | base64 --decode > gpg_key
gpg --import --no-tty --batch --yes gpg_key
rm gpg_key
./mill -i mill.scalalib.PublishModule/publishAll \
--sonatypeCreds $SONATYPE_USER:$SONATYPE_PASSWORD \
--gpgArgs --passphrase=$SONATYPE_PGP_PRIVATE_KEY_PASSWORD,--no-tty,--pinentry-mode,loopback,--batch,--yes,-a,-b \
--publishArtifacts __.publishArtifacts \
--readTimeout 600000 \
--awaitTimeout 600000 \
--release true \
--signed true
fi
- name: Create GitHub Release
id: create_gh_release
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*/target/
target/
output/
.DS_STORE
.idea_modules
.idea
.vscode/
out/
/.bloop/
/.metals/
mill.iml
.bsp/
bsp.log
lowered.hnir
.dotty-ide*
200 changes: 200 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# unroll


Unroll provides the `@unroll.Unroll("foo")` annotation that can be applied
to methods, classes, and constructors. `@Unroll` generates unrolled/telescoping
versions of the method, starting from the parameter specified by `"foo"`, which
are simple forwarders to the primary method or constructor implementation.

In the past, evolving code in Scala while maintaining binary compatibility was a pain.
You couldn't use default parameters, you couldn't use case classes. Many people fell
back to Java-style builder patterns and factories with `.withFoo` everywhere to maintain binary
compatibility. Or you would tediously define tons of binary compatibility stub methods
that just copy-paste the original signature and forward the call to the new implementation.

In effect, you often gave up everything that made Scala nice to read and write, because
the alternative was worse: every time you added a new parameter to a method, even though
it has a default value, all your users would have to recompile all their code. And all
*their* users would need to re-compile all their code, transitively. And so library
maintainers would suffer so their users could have a smooth upgrading experience.

With `@Unroll`, none of this is a problem anymore. You can add new parameters
where-ever you like: method `def`s, `class`es, `case class`es, etc. As long as the
new parameter has a default value, you can `@Unroll` it to generate the binary-compatibility
stub forwarder method automatically. Happy library maintainers, happy users, everyone is happy!

See this original discussion for more context:

* https://contributors.scala-lang.org/t/can-we-make-adding-a-parameter-with-a-default-value-binary-compatible

## Usage

### Methods

```scala
import unroll.Unroll

object Unrolled{
def foo(s: String, n: Int = 1, @Unroll b: Boolean = true, l: Long = 0) = s + n + b + l
}
```

Unrolls to:

```scala
import unroll.Unroll

object Unrolled{
def foo(s: String, n: Int = 1, @Unroll b: Boolean = true, l: Long = 0) = s + n + b + l

def foo(s: String, n: Int, b: Boolean) = foo(s, n, b, 0)
def foo(s: String, n: Int) = foo(s, n, true, 0)
}
````
### Classes

```scala
import unroll.Unroll

class Unrolled(s: String, n: Int = 1, @Unroll b: Boolean = true, l: Long = 0){
def foo = s + n + b + l
}
```

Unrolls to:

```scala
import unroll.Unroll

class Unrolled(s: String, n: Int = 1, @Unroll b: Boolean = true, l: Long = 0){
def foo = s + n + b + l

def this(s: String, n: Int, b: Boolean) = this(s, n, b, 0)
def this(s: String, n: Int) = this(s, n, true, 0)
}
```

### Constructors

```scala
import unroll.Unroll

class Unrolled() {
var foo = ""

def this(s: String, n: Int = 1, @Unroll b: Boolean = true, l: Long = 0) = {
this()
foo = s + n + b + l
}
}
```

Unrolls to:

```scala
import unroll.Unroll

class Unrolled() {
var foo = ""

def this(s: String, n: Int = 1, @Unroll b: Boolean = true, l: Long = 0) = {
this()
foo = s + n + b + l
}

def this(s: String, n: Int, b: Boolean) = this(s, n, b, 0)
def this(s: String, n: Int) = this(s, n, true, 0)
}
```

### Case Classes

```scala
import unroll.Unroll

case class Unrolled(s: String, n: Int = 1, @Unroll b: Boolean = true){
def foo = s + n + b
}
```

Unrolls to:

```scala
import unroll.Unroll

case class Unrolled(s: String, n: Int = 1, @Unroll b: Boolean = true, l: Long = 0L){
def this(s: String, n: Int) = this(s, n, true, 0L)
def this(s: String, n: Int, b: Boolean) = this(s, n, b, 0L)

def copy(s: String, n: Int) = copy(s, n, true, 0L)
def copy(s: String, n: Int, b: Boolean) = copy(s, n, b, 0L)

def foo = s + n + b
}
object Unrolled{
def apply(s: String, n: Int) = apply(s, n, true, 0L)
def apply(s: String, n: Int, b: Boolean) = apply(s, n, b, , 0L)
}
```


## Limitations

1. Only the first parameter list of multi-parameter list methods (i.e. curried or taking
implicits) can be unrolled. This is an implementation restriction that may be lifted
with a bit of work

2. As unrolling generates synthetic forwarder methods for binary compatibility, it is
possible for them to collide if your unrolled method has manually-defined overloads

3. Unrolled case classes are only fully binary compatible in Scala 3, though they are
_almost_ binary compatible in Scala 2. Direct calls to `unapply` are binary incompatible,
but most common pattern matching of `case class`es goes through a different code path
that _is_ binary compatible. In practice this should be sufficient for 99% of use cases,
but it does means that it is possible for code written as below to fail in Scala 2
if a new unrolled parameter is added to the case class `Unrolled`.

```scala
def foo(t: (String, Int)) = println(t)
Unrolled.unapply(unrolled).map(foo)
```

`unapply` is not a binary compatibility issue in Scala 3, even when called directly, due to
[Option-less Pattern Matching](https://docs.scala-lang.org/scala3/reference/changed-features/pattern-matching.html)

## Testing

Unroll is tested via a range of test-cases: `classMethod`, `objectMethod`, etc. These
are organized in `build.sc`, to take advantage of the build system's ability to wire up
compilation and classpaths

Each of these cases has three versions, `v1` `v2` `v3`, each of which has
different numbers of default parameters

For each test-case, we have the following tests:

1. `unroll[<scala-version>].tests[<test-case>]`: Using java reflection to make
sure the correct methods are generated in version `v3` and are callable with the
correct output

2. `unroll[<scala-version>].tests[<test-case>].{v1,v2,v3}.test`: Tests compiled against
the respective version of a test-case and running against the same version

3. `unroll[<scala-version>].tests[<test-case>].{v1v2,v2v3,v1v3}.test`: Tests compiled
an *older* version of a test-case but running against *newer* version. This simulates
a downstream library compiled against an older version of an upstream library but
running against a newer version, ensuring there is backwards binary compatibility

4. `unroll[<scala-version>].tests[<test-case>].{v1,v2,v3}.mimaReportBinaryIssues`: Running
the Scala [MiMa Migration Manager](https://github.com/lightbend/mima) to check a newer
version of test-case against an older version for binary compatibility

You can also run the following command to run all tests:

```bash
./mill -i -w "unroll[_].tests.__.run"
```

This can be useful as a final sanity check, even though you usually want to run
a subset of the tests specific to the `scala-version` and `test-case` you are
interested in.
Loading

0 comments on commit 89df86d

Please sign in to comment.