diff --git a/.github/run.sh b/.github/run.sh index 9f32449..3e09d8c 100755 --- a/.github/run.sh +++ b/.github/run.sh @@ -45,8 +45,8 @@ fi if (( no_lint == 0 )); then if [[ -z "${CI}" ]]; then - ./mill mill.scalalib.scalafmt.ScalafmtModule/reformatAll modules[_].sources + ./mill mill.scalalib.scalafmt.ScalafmtModule/reformatAll modules[_].__.sources else - ./mill mill.scalalib.scalafmt.ScalafmtModule/checkFormatAll modules[_].sources + ./mill mill.scalalib.scalafmt.ScalafmtModule/checkFormatAll modules[_].__.sources fi fi diff --git a/.mill-version b/.mill-version index aa22d3c..e01e0dd 100644 --- a/.mill-version +++ b/.mill-version @@ -1 +1 @@ -0.12.3 +0.12.4 diff --git a/README.md b/README.md index 180620a..848339e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ The older code is available in branches. 7. [Monoids and Semigroups](ch07) 8. [Functors](ch08) 9. [Monads](ch09) +10. [Monad Transformers](ch10) +11. [Semigroupal and Applicative](ch11) +12. [Foldable and Traverse](ch12) ## Running tests ``` diff --git a/build.mill b/build.mill index 30b443a..aea893f 100644 --- a/build.mill +++ b/build.mill @@ -38,11 +38,9 @@ trait CatsModule extends ScalaModule with Cross.Module[String] with ScalafmtModu ) object test extends ScalaTests with TestModule.ScalaTest { - val commonDeps = Seq( + def ivyDeps = Agg( ivy"org.scalatest::scalatest:${v.scalatestVersion}", ivy"org.scalatestplus::scalacheck-1-18:${v.scalacheckVersion}" ) - - def ivyDeps = Task{commonDeps} } } diff --git a/ch02/test/src/MyListSpec.scala b/ch02/test/src/MyListSpec.scala index 798e34a..6675e41 100644 --- a/ch02/test/src/MyListSpec.scala +++ b/ch02/test/src/MyListSpec.scala @@ -30,10 +30,3 @@ class MyListSpec extends AnyFunSpec: it("evens"): val actual = MyList.iterate(0, 5)(_ + 1).map(_ * 2) actual.toSeq shouldBe (0 to 8 by 2) - - - - - - - diff --git a/ch03/test/src/BoolSpec.scala b/ch03/test/src/BoolSpec.scala index eada01e..eb35f72 100644 --- a/ch03/test/src/BoolSpec.scala +++ b/ch03/test/src/BoolSpec.scala @@ -20,4 +20,3 @@ class BoolSpec extends AnyFunSpec: it("not"): not(True).`if`("yes")("no") shouldBe "no" not(False).`if`("yes")("no") shouldBe "yes" - diff --git a/ch03/test/src/CodataSpec.scala b/ch03/test/src/CodataSpec.scala index ec88cb4..73f2265 100644 --- a/ch03/test/src/CodataSpec.scala +++ b/ch03/test/src/CodataSpec.scala @@ -11,4 +11,3 @@ class CodataSpec extends AnyFunSpec: val product = list()(1, (a, b) => a * b) product shouldBe 6 - diff --git a/ch03/test/src/SetSpec.scala b/ch03/test/src/SetSpec.scala index 23034bc..6ca901e 100644 --- a/ch03/test/src/SetSpec.scala +++ b/ch03/test/src/SetSpec.scala @@ -11,9 +11,8 @@ class SetSpec extends AnyFunSpec: Evens.union(ListSet.empty.insert(1).insert(3)) evensAndOne.contains(1) shouldBe true - evensAndOthers.contains(1) shouldBe true - evensAndOne.contains(2) shouldBe true - evensAndOthers.contains(2) shouldBe true - evensAndOne.contains(3) shouldBe false - evensAndOthers.contains(3) shouldBe true - + evensAndOthers.contains(1) shouldBe true + evensAndOne.contains(2) shouldBe true + evensAndOthers.contains(2) shouldBe true + evensAndOne.contains(3) shouldBe false + evensAndOthers.contains(3) shouldBe true diff --git a/ch03/test/src/StreamSpec.scala b/ch03/test/src/StreamSpec.scala index 4b2e0b5..8738d7f 100644 --- a/ch03/test/src/StreamSpec.scala +++ b/ch03/test/src/StreamSpec.scala @@ -18,6 +18,3 @@ class StreamSpec extends AnyFunSpec: Stream.naturals.take(5) shouldBe (1 to 5) Stream.naturals2.take(5) shouldBe (1 to 5) Stream.naturals3.take(5) shouldBe (1 to 5) - - - diff --git a/ch05/test/src/ExpressionCSpec.scala b/ch05/test/src/ExpressionCSpec.scala index 662dd56..5461d10 100644 --- a/ch05/test/src/ExpressionCSpec.scala +++ b/ch05/test/src/ExpressionCSpec.scala @@ -8,4 +8,3 @@ class ExpressionCSpec extends AnyFunSpec: it("eval"): val fortyTwo = ((ExpressionC(15.0) + ExpressionC(5.0)) * ExpressionC(2.0) + ExpressionC(2.0)) / ExpressionC(1.0) fortyTwo.eval shouldBe 42.0d - diff --git a/ch05/test/src/ExpressionSpec.scala b/ch05/test/src/ExpressionSpec.scala index 618d285..4e99d53 100644 --- a/ch05/test/src/ExpressionSpec.scala +++ b/ch05/test/src/ExpressionSpec.scala @@ -8,4 +8,3 @@ class ExpressionSpec extends AnyFunSpec: it("eval"): val fortyTwo = ((Expression(15.0) + Expression(5.0)) * Expression(2.0) + Expression(2.0)) / Expression(1.0) fortyTwo.eval shouldBe 42.0d - diff --git a/ch05/test/src/ExpressionTSpec.scala b/ch05/test/src/ExpressionTSpec.scala index b9c1d3b..6e52bc7 100644 --- a/ch05/test/src/ExpressionTSpec.scala +++ b/ch05/test/src/ExpressionTSpec.scala @@ -8,4 +8,3 @@ class ExpressionTSpec extends AnyFunSpec: it("eval"): val fortyTwo = ((ExpressionT(15.0) + ExpressionT(5.0)) * ExpressionT(2.0) + ExpressionT(2.0)) / ExpressionT(1.0) fortyTwo.eval shouldBe 42.0d - diff --git a/ch05/test/src/RegexpCSpec.scala b/ch05/test/src/RegexpCSpec.scala index b2fd628..7b73d2f 100644 --- a/ch05/test/src/RegexpCSpec.scala +++ b/ch05/test/src/RegexpCSpec.scala @@ -9,17 +9,16 @@ class RegexpCSpec extends AnyFunSpec: it("matches"): val txts = Table( - ("txt", "match"), - ("Scala", true), - ("Scalalalala", true), - ("Sca", false), - ("Scalal", false), - ("Scalaland", false) + ("txt", "match"), + ("Scala", true), + ("Scalalalala", true), + ("Sca", false), + ("Scalal", false), + ("Scalaland", false) ) // left-associative val regexp = RegexpC("Sca") ++ RegexpC("la") ++ RegexpC("la").repeat - forAll (txts) { (txt: String, `match`: Boolean) => + forAll(txts) { (txt: String, `match`: Boolean) => regexp.matches(txt) shouldBe `match` } - diff --git a/ch05/test/src/RegexpSpec.scala b/ch05/test/src/RegexpSpec.scala index 64f30bd..2dea72c 100644 --- a/ch05/test/src/RegexpSpec.scala +++ b/ch05/test/src/RegexpSpec.scala @@ -9,17 +9,16 @@ class RegexpSpec extends AnyFunSpec: it("matches"): val txts = Table( - ("txt", "match"), - ("Scala", true), - ("Scalalalala", true), - ("Sca", false), - ("Scalal", false), - ("Scalaland", false) + ("txt", "match"), + ("Scala", true), + ("Scalalalala", true), + ("Sca", false), + ("Scalal", false), + ("Scalaland", false) ) // left-associative val regexp = Regexp("Sca") ++ Regexp("la") ++ Regexp("la").repeat - forAll (txts) { (txt: String, `match`: Boolean) => + forAll(txts) { (txt: String, `match`: Boolean) => regexp.matches(txt) shouldBe `match` } - diff --git a/ch05/test/src/RegexpTSpec.scala b/ch05/test/src/RegexpTSpec.scala index 9096b06..e8b9034 100644 --- a/ch05/test/src/RegexpTSpec.scala +++ b/ch05/test/src/RegexpTSpec.scala @@ -7,4 +7,3 @@ class RegexpTSpec extends AnyFunSpec: describe("RegexpT"): it("matches"): RegexpT("a").repeat.matches("a" * 20000) shouldBe true - diff --git a/ch06/test/src/CatSpec.scala b/ch06/test/src/CatSpec.scala index 29eacf1..6de6896 100644 --- a/ch06/test/src/CatSpec.scala +++ b/ch06/test/src/CatSpec.scala @@ -6,19 +6,19 @@ import cats.syntax.show.toShow import cats.syntax.eq.catsSyntaxEq class CatSpec extends AnyFunSpec: - describe("Cat"): - it("Show"): - Cat("Garfield", 41, "ginger and black").show shouldBe "Garfield is a 41 year-old ginger and black cat." + describe("Cat"): + it("Show"): + Cat("Garfield", 41, "ginger and black").show shouldBe "Garfield is a 41 year-old ginger and black cat." - it("Eq"): - val cat1 = Cat("Garfield", 38, "orange and black") - val cat2 = Cat("Heathcliff", 32, "orange and black") + it("Eq"): + val cat1 = Cat("Garfield", 38, "orange and black") + val cat2 = Cat("Heathcliff", 32, "orange and black") - cat1 === cat2 shouldBe false - cat1 =!= cat2 shouldBe true + cat1 === cat2 shouldBe false + cat1 =!= cat2 shouldBe true - val optionCat1 = Option(cat1) - val optionCat2 = Option.empty[Cat] + val optionCat1 = Option(cat1) + val optionCat2 = Option.empty[Cat] - optionCat1 === optionCat2 shouldBe false - optionCat1 =!= optionCat2 shouldBe true \ No newline at end of file + optionCat1 === optionCat2 shouldBe false + optionCat1 =!= optionCat2 shouldBe true diff --git a/ch07/test/src/LibSpec.scala b/ch07/test/src/LibSpec.scala index 2c97eb4..3a12759 100644 --- a/ch07/test/src/LibSpec.scala +++ b/ch07/test/src/LibSpec.scala @@ -19,4 +19,4 @@ class LibSpec extends AnyFunSpec: it("should add options"): val opts = List(Option(22), Option(20)) - add(opts) shouldBe Option(42) \ No newline at end of file + add(opts) shouldBe Option(42) diff --git a/ch08/test/src/TreeSpec.scala b/ch08/test/src/TreeSpec.scala index 3c4e964..bc73a7a 100644 --- a/ch08/test/src/TreeSpec.scala +++ b/ch08/test/src/TreeSpec.scala @@ -1,7 +1,7 @@ package ch08 import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers.shouldBe -import cats.syntax.functor.toFunctorOps // map +import cats.syntax.functor.toFunctorOps // map class TreeSpec extends AnyFunSpec: describe("Tree Functor"): @@ -11,4 +11,4 @@ class TreeSpec extends AnyFunSpec: it("should map on branch"): val actual = Tree.branch(Tree.leaf(10), Tree.leaf(20)).map(_ * 2) - actual shouldBe Tree.branch(Tree.leaf(20), Tree.leaf(40)) \ No newline at end of file + actual shouldBe Tree.branch(Tree.leaf(20), Tree.leaf(40)) diff --git a/ch09/test/src/LibSpec.scala b/ch09/test/src/LibSpec.scala index 3216d3d..ed729ea 100644 --- a/ch09/test/src/LibSpec.scala +++ b/ch09/test/src/LibSpec.scala @@ -11,12 +11,12 @@ import scala.concurrent.{Await, Future} class LibSpec extends AnyFunSpec: describe("MonadError"): - it("validateAdult"): - Lib.validateAdult[Try](18).success.value shouldBe 18 - Lib.validateAdult[Try](8).failure.exception shouldBe an[IllegalArgumentException] - type ExceptionOr[A] = Either[Throwable, A] - Lib.validateAdult[ExceptionOr](-1).left.value shouldBe an[IllegalArgumentException] - + it("validateAdult"): + Lib.validateAdult[Try](18).success.value shouldBe 18 + Lib.validateAdult[Try](8).failure.exception shouldBe an[IllegalArgumentException] + type ExceptionOr[A] = Either[Throwable, A] + Lib.validateAdult[ExceptionOr](-1).left.value shouldBe an[IllegalArgumentException] + describe("Writer"): it("factorial should maintain the order of logging"): val computations = Future.sequence( diff --git a/ch09/test/src/TreeSpec.scala b/ch09/test/src/TreeSpec.scala index 900751b..dd7cf4f 100644 --- a/ch09/test/src/TreeSpec.scala +++ b/ch09/test/src/TreeSpec.scala @@ -2,14 +2,15 @@ package ch09 import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers.shouldBe -import cats.syntax.flatMap.toFlatMapOps // flatMap -import cats.syntax.functor.toFunctorOps // map +import cats.syntax.flatMap.toFlatMapOps // flatMap +import cats.syntax.functor.toFunctorOps // map import Tree.given class TreeSpec extends AnyFunSpec: describe("Tree monad"): it("should support flatMap, map, and for-comprehension"): - val actual = Tree.branch(Tree.leaf(100), Tree.leaf(200)) + val actual = Tree + .branch(Tree.leaf(100), Tree.leaf(200)) .flatMap(x => Tree.branch(Tree.leaf(x - 1), Tree.leaf(x + 1))) val expected = Tree.branch( Tree.branch(Tree.leaf(99), Tree.leaf(101)), @@ -18,9 +19,9 @@ class TreeSpec extends AnyFunSpec: actual shouldBe expected val actual2 = for - a <- Tree.branch(Tree.leaf(100), Tree.leaf(200)) // flatMap - b <- Tree.branch(Tree.leaf(a - 10), Tree.leaf(a + 10)) // flatMap - c <- Tree.branch(Tree.leaf(b - 1), Tree.leaf(b + 1)) // map + a <- Tree.branch(Tree.leaf(100), Tree.leaf(200)) // flatMap + b <- Tree.branch(Tree.leaf(a - 10), Tree.leaf(a + 10)) // flatMap + c <- Tree.branch(Tree.leaf(b - 1), Tree.leaf(b + 1)) // map yield c val expected2 = Tree.branch( Tree.branch( diff --git a/ch10/src/Lib.scala b/ch10/src/Lib.scala new file mode 100644 index 0000000..b115b44 --- /dev/null +++ b/ch10/src/Lib.scala @@ -0,0 +1,63 @@ +package ch10 + +import scala.concurrent.Future +import cats.data.EitherT +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Await +import scala.concurrent.duration.* + +/* +10.4 Exercise: Monads: Transform and Roll Out + +The Autobots, well-known robots in disguise, frequently send messages +during battle requesting the power levels of their team mates. +This helps them coordinate strategies and launch devastating attacks. + +Transmissions take time in Earth’s viscous atmosphere, and messages are +occasionally lost due to satellite malfunction or sabotage by pesky Decepticons8. + +Optimus Prime is getting tired of the nested for comprehensions in his neural matrix. +Help him by rewriting Response using a monad transformer. + */ +object Lib: + + type Response[A] = EitherT[Future, String, A] + + val powerLevels = Map( + "Jazz" -> 6, + "Bumblebee" -> 8, + "Hot Rod" -> 10 + ) + + /* + Implement getPowerLevel to retrieve data from a set of imaginary allies. + If an Autobot isn’t in the powerLevels map, return an error message reporting + that they were unreachable. Include the name in the message for good effect. + */ + def getPowerLevel(ally: String): Response[Int] = + powerLevels.get(ally) match + case Some(avg) => EitherT.right(Future(avg)) + case None => EitherT.left(Future(s"$ally unreachable")) + + /* + Two autobots can perform a special move if their combined power level is greater than 15. + If either ally is unavailable, fail with an appropriate error message. + */ + def canSpecialMove(ally1: String, ally2: String): Response[Boolean] = + for + lvl1 <- getPowerLevel(ally1) + lvl2 <- getPowerLevel(ally2) + yield (lvl1 + lvl2) > 15 + + /* + Write a method tacticalReport that takes two ally names and prints + a message saying whether they can perform a special move. + */ + def tacticalReport(ally1: String, ally2: String): String = + val stack: Future[Either[String, Boolean]] = + canSpecialMove(ally1, ally2).value + + Await.result(stack, 1.second) match + case Left(msg) => s"Comms error: $msg" + case Right(true) => s"$ally1 and $ally2 are ready to roll out!" + case Right(false) => s"$ally1 and $ally2 need a recharge." diff --git a/ch10/src/ch10.worksheet.sc b/ch10/src/ch10.worksheet.sc new file mode 100644 index 0000000..f1c15fe --- /dev/null +++ b/ch10/src/ch10.worksheet.sc @@ -0,0 +1,75 @@ +import cats.data.{EitherT, OptionT, Writer} +import cats.syntax.applicative.catsSyntaxApplicativeId // pure +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.* +import scala.concurrent.{Await, Future} + +// 10.2 A Transformative Example +/* +we build ListOption from the inside out: we pass List, the type of the outer monad, +as a parameter to OptionT, the transformer for the inner monad. + */ +type ListOption[A] = OptionT[List, A] + +// We can create instances of ListOption using the OptionT constructor, or more conveniently using pure +val result1: ListOption[Int] = OptionT(List(Option(10))) +val result2: ListOption[Int] = 32.pure[ListOption] + +result1.flatMap { (x: Int) => + result2.map { (y: Int) => + x + y + } +} + +// 10.3.2 Building Monad Stacks + +// Alias Either to a type constructor with one parameter +type ErrorOr[A] = Either[String, A] + +// Build our final monad stack using OptionT +type ErrorOrOption[A] = OptionT[ErrorOr, A] + +val a = 10.pure[ErrorOrOption] +val b = 32.pure[ErrorOrOption] + +val c = a.flatMap(x => b.map(y => x + y)) + +type FutureEither[A] = EitherT[Future, String, A] + +type FutureEitherOption[A] = OptionT[FutureEither, A] + +val futureEitherOr: FutureEitherOption[Int] = + for + a <- 10.pure[FutureEitherOption] + b <- 32.pure[FutureEitherOption] + yield a + b + +// 10.3.3 Constructing and Unpacking Instances +val intermediate = futureEitherOr.value + +val stack = intermediate.value + +Await.result(stack, 1.second) + +// 10.3.5 Usage Patterns +type Logged[A] = Writer[List[String], A] + +// Methods generally return untransformed stacks +def parseNumber(str: String): Logged[Option[Int]] = + util.Try(str.toInt).toOption match + case Some(num) => Writer(List(s"Read $str"), Some(num)) + case None => Writer(List(s"Failed on $str"), None) + +// Consumers use monad transformers locally to simplify composition +def addAll(a: String, b: String, c: String): Logged[Option[Int]] = + val result = for + a <- OptionT(parseNumber(a)) + b <- OptionT(parseNumber(b)) + c <- OptionT(parseNumber(c)) + yield a + b + c + + result.value + +// This approach doesn't force OptionT on other users' code: +addAll("1", "2", "3") +addAll("1", "a", "3") diff --git a/ch10/test/src/Lib.scala b/ch10/test/src/Lib.scala new file mode 100644 index 0000000..79392d1 --- /dev/null +++ b/ch10/test/src/Lib.scala @@ -0,0 +1,16 @@ +package ch10 + +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers.shouldBe + +class LibSpec extends AnyFunSpec: + describe("Autobots"): + it("should generate a tactitcal report"): + val report1 = Lib.tacticalReport("Jazz", "Bumblebee") + report1 shouldBe "Jazz and Bumblebee need a recharge." + + val report2 = Lib.tacticalReport("Bumblebee", "Hot Rod") + report2 shouldBe "Bumblebee and Hot Rod are ready to roll out!" + + val report3 = Lib.tacticalReport("Jazz", "Ironhide") + report3 shouldBe "Comms error: Ironhide unreachable" diff --git a/ch11/src/ch11.worksheet.sc b/ch11/src/ch11.worksheet.sc new file mode 100644 index 0000000..a2ee4f6 --- /dev/null +++ b/ch11/src/ch11.worksheet.sc @@ -0,0 +1,59 @@ +import cats.Semigroupal +import cats.syntax.apply.* // tupled and mapN +import cats.syntax.parallel.* // parTupled + +// 11.1.1 Joining Two Contexts +/* +While Semigroup allows us to join values, Semigroupal allows us to join contexts. +If either parameter evaluates to None, the entire result is None. + */ +Semigroupal[Option].product(Some(123), Some("abc")) + +// 11.1.2 Joining Three or More Contexts +Semigroupal.tuple3(Option(1), Option(2), Option.empty[Int]) + +// 11.2 Apply Syntax +(Option(123), Option("abc")).tupled + +final case class Cat(name: String, born: Int, color: String) + +( + Option("Garfield"), + Option(1978), + Option("Orange & black") +).mapN(Cat.apply) + +/* +11.3.1.1 Exercise: The Product of Lists +Why does product for List produce the Cartesian product? + */ +(List(1, 2), List(3, 4)).tupled + +// def product[F[_]: Monad, A, B](x: F[A], y: F[B]): F[(A, B)] = +// x.flatMap(a => y.map(b => (a, b))) + +/* +^^^This code is equivalent to a for comprehension. +The semantics of flatMap are what give rise to the behaviour for List and Either. + */ + +// 11.4 Parallel +type ErrorOr[A] = Either[Vector[String], A] +val error1: ErrorOr[Int] = Left(Vector("Error 1")) +val error2: ErrorOr[Int] = Left(Vector("Error 2")) +Semigroupal[ErrorOr].product(error1, error2) + +(error1, error2).tupled + +// To collect all the errors we simply replace tupled with its "parallel" version called parTupled. +(error1, error2).parTupled + +/* +11.4.0.1 Exercise: Parallel List +Does List have a Parallel instance? If so, what does the Parallel instance do? + */ + +// List does have a Parallel instance, and it zips the List insted of creating the cartesian product. +(List(1, 2), List(3, 4)).parTupled + +// Semigroupal and Applicative effectively provide alternative encodings of the same notion of joining contexts. diff --git a/ch12/src/ch12.worksheet.sc b/ch12/src/ch12.worksheet.sc new file mode 100644 index 0000000..2fd06dc --- /dev/null +++ b/ch12/src/ch12.worksheet.sc @@ -0,0 +1,133 @@ +import cats.syntax.applicative.catsSyntaxApplicativeId +import cats.{Applicative, Traverse} +import scala.concurrent.{Await, Future} +import cats.data.Validated +import cats.syntax.apply.* // mapN +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.* +import cats.syntax.traverse.toTraverseOps // traverse, sequence + +/* +12.1.2 Exercise: Reflecting on Folds + +Try using foldLeft and foldRight with an empty list +as the accumulator and :: as the binary operator. + +What results do you get in each case? + */ + +// Folding from left to right reverses the list. +List(1, 2, 3).foldLeft(List.empty[Int])((a, i) => i :: a) + +// Folding right to left copies the list, leaving the order intact. +List(1, 2, 3).foldRight(List.empty[Int])((i, a) => i :: a) + +/* +12.1.3 Exercise: Scaf-fold-ing Other Methods + +Implement substitutes for List's map, flatMap, filter, and sum methods in terms of foldRight. + */ +def map[A, B](list: List[A])(f: A => B): List[B] = + list.foldRight(List.empty[B])((x, acc) => f(x) :: acc) + +def flatMap[A, B](list: List[A])(f: A => List[B]): List[B] = + // ::: == ++ + list.foldRight(List.empty[B])((x, acc) => f(x) ::: acc) + +def filter[A](list: List[A])(f: A => Boolean): List[A] = + list.foldRight(List.empty[A])((x, acc) => if f(x) then x :: acc else acc) + +def listTraverse[F[_]: Applicative, A, B](list: List[A])(func: A => F[B]): F[List[B]] = + list.foldLeft(List.empty[B].pure[F]) { (accum, item) => + (accum, func(item)).mapN(_ :+ _) + } + +def listSequence[F[_]: Applicative, B](list: List[F[B]]): F[List[B]] = + listTraverse(list)(identity) +/* +12.2.2.1 Exercise: Traversing with Vectors + +What is the result of the following? + +The argument is of type List[Vector[Int]], so we're using the Applicative +for Vector and the return type is going to be Vector[List[Int]]. +Vector is a monad, so its semigroupal combine function is based on flatMap. +We end up with a cross-product. + */ +listSequence(List(Vector(1, 2), Vector(3, 4))) + +/* +12.2.2.2 Exercise: Traversing with Options + +What is the return type of this method? What does it produce for the following inputs? + */ +def process(inputs: List[Int]) = + listTraverse(inputs)(n => if (n % 2 == 0) Some(n) else None) + +/* +The arguments to listTraverse are of types List[Int] and Int => Option[Int], +so, the return type is Option[List[Int]]. Again, Option is a monad, so, the +semigroupal combine function follows from flatMap. The semantics are, therefore, +fail-fast error handling: if all inputs are even, we get a list of outputs. +Otherwise we get None. + */ +process(List(2, 4, 6)) +process(List(1, 2, 3)) + +/* +12.2.2.3 Exercise: Traversing with Validated + +What does this method produce for the following inputs? + */ +type ErrorsOr[A] = Validated[List[String], A] + +def process2(inputs: List[Int]): ErrorsOr[List[Int]] = + listTraverse(inputs) { n => + if (n % 2 == 0) + then Validated.valid(n) + else Validated.invalid(List(s"$n is not even")) + } + +/* +The return type here is ErrorsOr[List[Int]], which expands to Validated[List[String], List[Int]]. +The semantics for semigroupal combine on validated are accumulating error handling, so, the +result is either a list of even Ints, or a list of errors detailing which Ints failed the test. + */ +process2(List(2, 4, 6)) +process2(List(1, 2, 3)) + +// 12.2.3 Traverse in Cats +val hostnames = List( + "alpha.example.com", + "beta.example.com", + "gamma.demo.com" +) + +def getUptime(hostname: String): Future[Int] = + Future(hostname.length * 60) + +val totalUptime: Future[List[Int]] = + Traverse[List].traverse(hostnames)(getUptime) + +Await.result(totalUptime, 1.second) + +val numbers = List(Future(1), Future(2), Future(3)) + +val numbers2: Future[List[Int]] = + Traverse[List].sequence(numbers) + +Await.result(numbers2, 1.second) + +Await.result(hostnames.traverse(getUptime), 1.second) + +Await.result(numbers.sequence, 1.second) + +/* +Foldable abstracts the foldLeft and foldRight methods we know from collections in the standard library. +It adds stack-safe implementations of these methods to a handful of extra data types, and defines a +host of situationally useful additions. That said, Foldable doesn't introduce much that we didn't already know. + +The real power comes from Traverse, which abstracts and generalises the traverse and sequence methods +we know from Future. Using these methods we can turn an F[G[A]] into a G[F[A]] for any F with an +instance of Traverse and any G with an instance of Applicative. + */ diff --git a/mill b/mill index 81ca72e..44386d6 100755 --- a/mill +++ b/mill @@ -1,51 +1,222 @@ #!/usr/bin/env sh # This is a wrapper script, that automatically download mill from GitHub release pages -# You can give the required mill version with MILL_VERSION env variable +# You can give the required mill version with --mill-version parameter # If no version is given, it falls back to the value of DEFAULT_MILL_VERSION +# +# Original Project page: https://github.com/lefou/millw +# Script Version: 0.4.12 +# +# If you want to improve this script, please also contribute your changes back! +# +# Licensed under the Apache License, Version 2.0 set -e if [ -z "${DEFAULT_MILL_VERSION}" ] ; then - DEFAULT_MILL_VERSION=0.11.11 + DEFAULT_MILL_VERSION=0.12.4 fi -if [ -z "$MILL_VERSION" ] ; then + +if [ -z "${GITHUB_RELEASE_CDN}" ] ; then + GITHUB_RELEASE_CDN="" +fi + + +MILL_REPO_URL="https://github.com/com-lihaoyi/mill" + +if [ -z "${CURL_CMD}" ] ; then + CURL_CMD=curl +fi + +# Explicit commandline argument takes precedence over all other methods +if [ "$1" = "--mill-version" ] ; then + shift + if [ "x$1" != "x" ] ; then + MILL_VERSION="$1" + shift + else + echo "You specified --mill-version without a version." 1>&2 + echo "Please provide a version that matches one provided on" 1>&2 + echo "${MILL_REPO_URL}/releases" 1>&2 + false + fi +fi + +# Please note, that if a MILL_VERSION is already set in the environment, +# We reuse it's value and skip searching for a value. + +# If not already set, read .mill-version file +if [ -z "${MILL_VERSION}" ] ; then if [ -f ".mill-version" ] ; then - MILL_VERSION="$(head -n 1 .mill-version 2> /dev/null)" + MILL_VERSION="$(tr '\r' '\n' < .mill-version | head -n 1 2> /dev/null)" elif [ -f ".config/mill-version" ] ; then - MILL_VERSION="$(head -n 1 .config/mill-version 2> /dev/null)" - elif [ -f "mill" ] && [ "$0" != "mill" ] ; then - MILL_VERSION=$(grep -F "DEFAULT_MILL_VERSION=" "mill" | head -n 1 | cut -d= -f2) - else - MILL_VERSION=$DEFAULT_MILL_VERSION + MILL_VERSION="$(tr '\r' '\n' < .config/mill-version | head -n 1 2> /dev/null)" fi fi -if [ "x${XDG_CACHE_HOME}" != "x" ] ; then - MILL_DOWNLOAD_PATH="${XDG_CACHE_HOME}/mill/download" -else - MILL_DOWNLOAD_PATH="${HOME}/.cache/mill/download" +MILL_USER_CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/mill" + +if [ -z "${MILL_DOWNLOAD_PATH}" ] ; then + MILL_DOWNLOAD_PATH="${MILL_USER_CACHE_DIR}/download" +fi + +# If not already set, try to fetch newest from Github +if [ -z "${MILL_VERSION}" ] ; then + # TODO: try to load latest version from release page + echo "No mill version specified." 1>&2 + echo "You should provide a version via '.mill-version' file or --mill-version option." 1>&2 + + mkdir -p "${MILL_DOWNLOAD_PATH}" + LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" 2>/dev/null || ( + # we might be on OSX or BSD which don't have -d option for touch + # but probably a -A [-][[hh]mm]SS + touch "${MILL_DOWNLOAD_PATH}/.expire_latest"; touch -A -010000 "${MILL_DOWNLOAD_PATH}/.expire_latest" + ) || ( + # in case we still failed, we retry the first touch command with the intention + # to show the (previously suppressed) error message + LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" + ) + + # POSIX shell variant of bash's -nt operator, see https://unix.stackexchange.com/a/449744/6993 + # if [ "${MILL_DOWNLOAD_PATH}/.latest" -nt "${MILL_DOWNLOAD_PATH}/.expire_latest" ] ; then + if [ -n "$(find -L "${MILL_DOWNLOAD_PATH}/.latest" -prune -newer "${MILL_DOWNLOAD_PATH}/.expire_latest")" ]; then + # we know a current latest version + MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) + fi + + if [ -z "${MILL_VERSION}" ] ; then + # we don't know a current latest version + echo "Retrieving latest mill version ..." 1>&2 + LANG=C ${CURL_CMD} -s -i -f -I ${MILL_REPO_URL}/releases/latest 2> /dev/null | grep --ignore-case Location: | sed s'/^.*tag\///' | tr -d '\r\n' > "${MILL_DOWNLOAD_PATH}/.latest" + MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) + fi + + if [ -z "${MILL_VERSION}" ] ; then + # Last resort + MILL_VERSION="${DEFAULT_MILL_VERSION}" + echo "Falling back to hardcoded mill version ${MILL_VERSION}" 1>&2 + else + echo "Using mill version ${MILL_VERSION}" 1>&2 + fi fi -MILL_EXEC_PATH="${MILL_DOWNLOAD_PATH}/${MILL_VERSION}" -version_remainder="$MILL_VERSION" -MILL_MAJOR_VERSION="${version_remainder%%.*}"; version_remainder="${version_remainder#*.}" -MILL_MINOR_VERSION="${version_remainder%%.*}"; version_remainder="${version_remainder#*.}" +MILL="${MILL_DOWNLOAD_PATH}/${MILL_VERSION}" -if [ ! -s "$MILL_EXEC_PATH" ] ; then - mkdir -p "$MILL_DOWNLOAD_PATH" - if [ "$MILL_MAJOR_VERSION" -gt 0 ] || [ "$MILL_MINOR_VERSION" -ge 5 ] ; then - ASSEMBLY="-assembly" +try_to_use_system_mill() { + if [ "$(uname)" != "Linux" ]; then + return 0 + fi + + MILL_IN_PATH="$(command -v mill || true)" + + if [ -z "${MILL_IN_PATH}" ]; then + return 0 + fi + + SYSTEM_MILL_FIRST_TWO_BYTES=$(head --bytes=2 "${MILL_IN_PATH}") + if [ "${SYSTEM_MILL_FIRST_TWO_BYTES}" = "#!" ]; then + # MILL_IN_PATH is (very likely) a shell script and not the mill + # executable, ignore it. + return 0 + fi + + SYSTEM_MILL_PATH=$(readlink -e "${MILL_IN_PATH}") + SYSTEM_MILL_SIZE=$(stat --format=%s "${SYSTEM_MILL_PATH}") + SYSTEM_MILL_MTIME=$(stat --format=%y "${SYSTEM_MILL_PATH}") + + if [ ! -d "${MILL_USER_CACHE_DIR}" ]; then + mkdir -p "${MILL_USER_CACHE_DIR}" + fi + + SYSTEM_MILL_INFO_FILE="${MILL_USER_CACHE_DIR}/system-mill-info" + if [ -f "${SYSTEM_MILL_INFO_FILE}" ]; then + parseSystemMillInfo() { + LINE_NUMBER="${1}" + # Select the line number of the SYSTEM_MILL_INFO_FILE, cut the + # variable definition in that line in two halves and return + # the value, and finally remove the quotes. + sed -n "${LINE_NUMBER}p" "${SYSTEM_MILL_INFO_FILE}" |\ + cut -d= -f2 |\ + sed 's/"\(.*\)"/\1/' + } + + CACHED_SYSTEM_MILL_PATH=$(parseSystemMillInfo 1) + CACHED_SYSTEM_MILL_VERSION=$(parseSystemMillInfo 2) + CACHED_SYSTEM_MILL_SIZE=$(parseSystemMillInfo 3) + CACHED_SYSTEM_MILL_MTIME=$(parseSystemMillInfo 4) + + if [ "${SYSTEM_MILL_PATH}" = "${CACHED_SYSTEM_MILL_PATH}" ] \ + && [ "${SYSTEM_MILL_SIZE}" = "${CACHED_SYSTEM_MILL_SIZE}" ] \ + && [ "${SYSTEM_MILL_MTIME}" = "${CACHED_SYSTEM_MILL_MTIME}" ]; then + if [ "${CACHED_SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then + MILL="${SYSTEM_MILL_PATH}" + return 0 + else + return 0 + fi + fi + fi + + SYSTEM_MILL_VERSION=$(${SYSTEM_MILL_PATH} --version | head -n1 | sed -n 's/^Mill.*version \(.*\)/\1/p') + + cat <<EOF > "${SYSTEM_MILL_INFO_FILE}" +CACHED_SYSTEM_MILL_PATH="${SYSTEM_MILL_PATH}" +CACHED_SYSTEM_MILL_VERSION="${SYSTEM_MILL_VERSION}" +CACHED_SYSTEM_MILL_SIZE="${SYSTEM_MILL_SIZE}" +CACHED_SYSTEM_MILL_MTIME="${SYSTEM_MILL_MTIME}" +EOF + + if [ "${SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then + MILL="${SYSTEM_MILL_PATH}" + fi +} +try_to_use_system_mill + +# If not already downloaded, download it +if [ ! -s "${MILL}" ] ; then + + # support old non-XDG download dir + MILL_OLD_DOWNLOAD_PATH="${HOME}/.mill/download" + OLD_MILL="${MILL_OLD_DOWNLOAD_PATH}/${MILL_VERSION}" + if [ -x "${OLD_MILL}" ] ; then + MILL="${OLD_MILL}" + else + case $MILL_VERSION in + 0.0.* | 0.1.* | 0.2.* | 0.3.* | 0.4.* ) + DOWNLOAD_SUFFIX="" + DOWNLOAD_FROM_MAVEN=0 + ;; + 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.0-M* ) + DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=0 + ;; + *) + DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=1 + ;; + esac + + DOWNLOAD_FILE=$(mktemp mill.XXXXXX) + + if [ "$DOWNLOAD_FROM_MAVEN" = "1" ] ; then + DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist/${MILL_VERSION}/mill-dist-${MILL_VERSION}.jar" + else + MILL_VERSION_TAG=$(echo "$MILL_VERSION" | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') + DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${DOWNLOAD_SUFFIX}" + unset MILL_VERSION_TAG + fi + + # TODO: handle command not found + echo "Downloading mill ${MILL_VERSION} from ${DOWNLOAD_URL} ..." 1>&2 + ${CURL_CMD} -f -L -o "${DOWNLOAD_FILE}" "${DOWNLOAD_URL}" + chmod +x "${DOWNLOAD_FILE}" + mkdir -p "${MILL_DOWNLOAD_PATH}" + mv "${DOWNLOAD_FILE}" "${MILL}" + + unset DOWNLOAD_FILE + unset DOWNLOAD_SUFFIX fi - DOWNLOAD_FILE=$MILL_EXEC_PATH-tmp-download - MILL_VERSION_TAG=$(echo $MILL_VERSION | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') - MILL_DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist/$MILL_VERSION/mill-dist-$MILL_VERSION.jar" - curl --fail -L -o "$DOWNLOAD_FILE" "$MILL_DOWNLOAD_URL" - chmod +x "$DOWNLOAD_FILE" - mv "$DOWNLOAD_FILE" "$MILL_EXEC_PATH" - unset DOWNLOAD_FILE - unset MILL_DOWNLOAD_URL fi if [ -z "$MILL_MAIN_CLI" ] ; then @@ -53,15 +224,18 @@ if [ -z "$MILL_MAIN_CLI" ] ; then fi MILL_FIRST_ARG="" - - # first arg is a long flag for "--interactive" or starts with "-i" -if [ "$1" = "--bsp" ] || [ "${1#"-i"}" != "$1" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then +if [ "$1" = "--bsp" ] || [ "$1" = "-i" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then # Need to preserve the first position of those listed options MILL_FIRST_ARG=$1 shift fi unset MILL_DOWNLOAD_PATH +unset MILL_OLD_DOWNLOAD_PATH +unset OLD_MILL unset MILL_VERSION +unset MILL_REPO_URL -exec $MILL_EXEC_PATH $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" +# We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes +# shellcheck disable=SC2086 +exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" \ No newline at end of file