Skip to content

Commit

Permalink
Merge pull request #39 from armanbilge/feature/signal-monad
Browse files Browse the repository at this point in the history
Add `Monad[Signal]`
  • Loading branch information
armanbilge authored May 10, 2022
2 parents 59e01e0 + de0c7c2 commit 1c0752e
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 14 deletions.
16 changes: 13 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
os: [ubuntu-latest]
scala: [3.1.2]
java: [temurin@17]
project: [rootJS]
project: [rootJS, rootJVM]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout current branch (full)
Expand Down Expand Up @@ -90,11 +90,11 @@ jobs:

- name: Make target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
run: mkdir -p todo-mvc/target target unidocs/target .js/target site/target widget/target jsdocs/target .jvm/target .native/target example/target calico/target project/target
run: mkdir -p frp/.js/target todo-mvc/target target unidocs/target .js/target site/target widget/target jsdocs/target .jvm/target .native/target example/target frp/.jvm/target calico/target project/target

- name: Compress target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
run: tar cf targets.tar todo-mvc/target target unidocs/target .js/target site/target widget/target jsdocs/target .jvm/target .native/target example/target calico/target project/target
run: tar cf targets.tar frp/.js/target todo-mvc/target target unidocs/target .js/target site/target widget/target jsdocs/target .jvm/target .native/target example/target frp/.jvm/target calico/target project/target

- name: Upload target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
Expand Down Expand Up @@ -157,6 +157,16 @@ jobs:
tar xf targets.tar
rm targets.tar
- name: Download target directories (3.1.2, rootJVM)
uses: actions/download-artifact@v2
with:
name: target-${{ matrix.os }}-${{ matrix.java }}-3.1.2-rootJVM

- name: Inflate target directories (3.1.2, rootJVM)
run: |
tar xf targets.tar
rm targets.tar
- name: Import signing key
if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == ''
run: echo $PGP_SECRET | base64 -di | gpg --import
Expand Down
35 changes: 27 additions & 8 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,42 @@ ThisBuild / scalacOptions ++= Seq("-new-syntax", "-indent", "-source:future")
ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec.temurin("17"))
ThisBuild / tlJdkRelease := Some(8)

lazy val root = tlCrossRootProject.aggregate(calico, widget, example, todoMvc, unidocs)
val CatsVersion = "2.7.0"
val CatsEffectVersion = "3.3.11"
val MonocleVersion = "3.1.0"

lazy val root = tlCrossRootProject.aggregate(frp, calico, widget, example, todoMvc, unidocs)

lazy val frp = crossProject(JVMPlatform, JSPlatform)
.crossType(CrossType.Pure)
.in(file("frp"))
.settings(
name := "calico-frp",
tlVersionIntroduced := Map("3" -> "0.1.1"),
libraryDependencies ++= Seq(
"org.typelevel" %%% "cats-core" % CatsVersion,
"org.typelevel" %%% "cats-effect" % CatsEffectVersion,
"co.fs2" %%% "fs2-core" % "3.2.7",
"org.typelevel" %%% "cats-laws" % CatsVersion % Test,
"org.typelevel" %%% "cats-effect-testkit" % CatsEffectVersion % Test,
"org.typelevel" %%% "discipline-munit" % "1.0.9" % Test,
"org.scalameta" %%% "munit-scalacheck" % "0.7.29" % Test
)
)

lazy val calico = project
.in(file("calico"))
.enablePlugins(ScalaJSPlugin)
.settings(
name := "calico",
libraryDependencies ++= Seq(
"org.typelevel" %%% "cats-core" % "2.7.0",
"org.typelevel" %%% "cats-effect" % "3.3.11",
"co.fs2" %%% "fs2-core" % "3.2.7",
"org.typelevel" %%% "shapeless3-deriving" % "3.0.4",
"dev.optics" %%% "monocle-core" % "3.1.0",
"dev.optics" %%% "monocle-core" % MonocleVersion,
"com.raquo" %%% "domtypes" % "0.16.0-RC2",
"org.scala-js" %%% "scalajs-dom" % "2.1.0"
)
)
.dependsOn(frp.js)

lazy val widget = project
.in(file("widget"))
Expand All @@ -52,7 +71,7 @@ lazy val example = project
.withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("calico")))
},
libraryDependencies ++= Seq(
"dev.optics" %%% "monocle-macro" % "3.1.0"
"dev.optics" %%% "monocle-macro" % MonocleVersion
)
)

Expand All @@ -68,7 +87,7 @@ lazy val todoMvc = project
.withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("todomvc")))
},
libraryDependencies ++= Seq(
"dev.optics" %%% "monocle-macro" % "3.1.0"
"dev.optics" %%% "monocle-macro" % MonocleVersion
)
)

Expand All @@ -77,7 +96,7 @@ lazy val unidocs = project
.enablePlugins(ScalaJSPlugin, TypelevelUnidocPlugin)
.settings(
name := "calico-docs",
ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(calico)
ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(frp.js, calico)
)

lazy val jsdocs = project.dependsOn(calico, widget).enablePlugins(ScalaJSPlugin)
Expand Down
89 changes: 89 additions & 0 deletions frp/src/main/scala/calico/frp/frp.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2022 Arman Bilge
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/*
* Copyright (c) 2013 Functional Streams for Scala
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package calico
package frp

import cats.Monad
import cats.StackSafeMonad
import cats.data.OptionT
import cats.effect.kernel.Concurrent
import cats.syntax.all.*
import fs2.Pull
import fs2.Stream
import fs2.concurrent.Signal

given [F[_]: Concurrent]: Monad[Signal[F, _]] = new StackSafeMonad[Signal[F, _]]:
def pure[A](a: A) = Signal.constant(a)

override def map[A, B](siga: Signal[F, A])(f: A => B) =
Signal.mapped(siga)(f)

def flatMap[A, B](siga: Signal[F, A])(f: A => Signal[F, B]) = new:
def get = siga.get.flatMap(f(_).get)
def continuous = Stream.repeatEval(get)
def discrete = siga.discrete.switchMap(f(_).discrete)

override def ap[A, B](ff: Signal[F, A => B])(fa: Signal[F, A]) =
new:
def discrete: Stream[F, B] =
nondeterministicZip(ff.discrete, fa.discrete).map(_(_))
def continuous: Stream[F, B] = Stream.repeatEval(get)
def get: F[B] = ff.get.ap(fa.get)

private def nondeterministicZip[A0, A1](
xs: Stream[F, A0],
ys: Stream[F, A1]
): Stream[F, (A0, A1)] =
type PullOutput = (A0, A1, Stream[F, A0], Stream[F, A1])

val firstPull: OptionT[Pull[F, PullOutput, *], Unit] = for
firstXAndRestOfXs <- OptionT(xs.pull.uncons1.covaryOutput[PullOutput])
(x, restOfXs) = firstXAndRestOfXs
firstYAndRestOfYs <- OptionT(ys.pull.uncons1.covaryOutput[PullOutput])
(y, restOfYs) = firstYAndRestOfYs
_ <- OptionT.liftF {
Pull.output1[F, PullOutput]((x, y, restOfXs, restOfYs)): Pull[F, PullOutput, Unit]
}
yield ()

firstPull.value.void.stream.flatMap { (x, y, restOfXs, restOfYs) =>
restOfXs.either(restOfYs).scan((x, y)) {
case ((_, rightElem), Left(newElem)) => (newElem, rightElem)
case ((leftElem, _), Right(newElem)) => (leftElem, newElem)
}
}
92 changes: 92 additions & 0 deletions frp/src/test/scala/calico/frp/SignalSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2022 Arman Bilge
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package calico.frp

import cats.data.NonEmptyList
import cats.effect.IO
import cats.effect.kernel.Resource
import cats.effect.testkit.TestControl
import cats.effect.testkit.TestInstances
import cats.kernel.Eq
import cats.laws.discipline.ApplicativeTests
import cats.laws.discipline.MonadTests
import cats.syntax.all.*
import fs2.Stream
import fs2.concurrent.Signal
import fs2.concurrent.SignallingRef
import munit.DisciplineSuite
import org.scalacheck.Arbitrary
import org.scalacheck.Arbitrary.arbitrary
import org.scalacheck.Gen

import scala.concurrent.duration.*

class SignalSuite extends DisciplineSuite, TestInstances:

override def scalaCheckTestParameters =
if sys.props("java.vm.name").contains("Scala.js") then
super.scalaCheckTestParameters.withMinSuccessfulTests(10).withMaxSize(10)
else super.scalaCheckTestParameters

case class TestSignal[A](events: NonEmptyList[(FiniteDuration, A)]) extends Signal[IO, A]:
def discrete: Stream[IO, A] = Stream.eval(IO.realTime).flatMap { now =>
def go(events: NonEmptyList[(FiniteDuration, A)]): (A, List[(FiniteDuration, A)]) =
events match
case NonEmptyList((_, a), Nil) => (a, Nil)
case NonEmptyList((t0, a0), tail @ ((t1, a1) :: _)) =>
if t1 > now then (a0, tail)
else go(NonEmptyList.fromListUnsafe(tail))

val (current, remaining) = go(events)
Stream.emit(current) ++ Stream.emits(remaining).evalMap { (when, a) =>
IO.realTime.map(when - _).flatMap(IO.sleep).as(a)
}
}
def get = IO.never
def continuous = Stream.never

given [A: Arbitrary]: Arbitrary[Signal[IO, A]] =
given Arbitrary[FiniteDuration] = Arbitrary(Gen.posNum[Byte].map(_.toLong.millis))
Arbitrary(
for
initial <- arbitrary[A]
tail <- arbitrary[List[(FiniteDuration, A)]]
events = tail.scanLeft(Duration.Zero -> initial) {
case ((prevTime, _), (sleep, a)) =>
(prevTime + sleep) -> a
}
yield TestSignal(NonEmptyList.fromListUnsafe(events))
)

given [A: Eq](using Eq[IO[List[(A, FiniteDuration)]]]): Eq[Signal[IO, A]] = Eq.by { sig =>
IO.ref(List.empty[(A, FiniteDuration)]).flatMap { ref =>
TestControl.executeEmbed(
sig
.discrete
.evalMap(IO.realTime.tupleLeft(_))
.evalMap(x => ref.update(x :: _))
.compile
.drain
.timeoutTo(Long.MaxValue.nanos, IO.unit)
) *> ref.get.map(_.distinctBy(_._2))
}
}

given Ticker = Ticker()

// it is stack-safe, but expensive to test
checkAll("Signal", MonadTests[Signal[IO, _]].stackUnsafeMonad[Int, Int, Int])
8 changes: 5 additions & 3 deletions todo-mvc/src/main/scala/todomvc/TodoMvc.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package todomvc

import calico.*
import calico.dsl.io.*
import calico.frp.given
import calico.syntax.*
import cats.effect.*
import cats.effect.std.*
Expand All @@ -40,7 +41,8 @@ object TodoMvc extends IOWebApp:
cls := "main",
ul(
cls := "todo-list",
children[Int](id => TodoItem(store.entry(id))) <-- store.ids(filter)
children[Int](id => TodoItem(store.entry(id))) <--
(filter: Signal[IO, Filter]).flatMap(store.ids(_))
)
),
store
Expand Down Expand Up @@ -147,8 +149,8 @@ class TodoStore(map: SignallingRef[IO, SortedMap[Int, Todo]], nextId: Ref[IO, In
def entry(id: Int): SignallingRef[IO, Option[Todo]] =
map.zoom(At.atSortedMap[Int, Todo].at(id))

def ids(filter: Signal[IO, Filter]): Signal[IO, List[Int]] =
(map, filter).mapN((m, f) => m.filter((_, t) => f.pred(t)).keySet.toList)
def ids(filter: Filter): Signal[IO, List[Int]] =
map.map(_.filter((_, t) => filter.pred(t)).keySet.toList)

def size: Signal[IO, Int] = map.map(_.size)

Expand Down

0 comments on commit 1c0752e

Please sign in to comment.