diff --git a/README.md b/README.md index 35cb2c8..6e93968 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -# Catscript +# Catscript 😸 +### _Making scripts in Scala much easier!_ +![Continuous Integration](https://github.com/typelevel/catscript/workflows/Continuous%20Integration/badge.svg) + +## Overview +Catscript is a library for working with files (and soon processes) using pure [`IO`](https://typelevel.org/cats-effect/docs/getting-started), designed to make things much easier. + +**Before** +```scala 3 +Files[IO] + .readUtf8(path) + .evalMap(IO.println(_)) + .compile + .drain +``` + +**After** +``` scala 3 +path.read >>= IO.println +``` + +It does this by drastically reducing the complexity of the `Files' API and getting rid of difficult concepts that are not so necessary for scripting. + +## Getting Started + +You can use Catscript in a new or existing Scala 2.13.x or 3.x project by adding it to your `build.sbt` file: + +```scala +libraryDependencies ++= List( + "org.typelevel" %% "catscript" % latest +) +``` + +## Example +Catscript is a library to perform common script operations such as working with processes and files while maintaining referential transparency! + +```scala 3 mdoc:reset +import cats.effect.{IO, IOApp, ExitCode} + +import catscript.* +import catscript.syntax.path.* + +object Main extends IOApp: + + def run(args: List[String]): IO[ExitCode] = + for + home <- userHome + config = home / ".catscript" / "config.conf" + _ <- config.createFile + _ <- config.write("scripting.made.easy = true") + newconfig <- config.read + _ <- IO.println(s"Loading config: $newconfig") + yield ExitCode.Success + +end Main +``` diff --git a/docs/directory.conf b/docs/directory.conf index 08f25e2..60ee84b 100644 --- a/docs/directory.conf +++ b/docs/directory.conf @@ -1,3 +1,7 @@ laika.navigationOrder = [ index.md -] + introduction + files + wiki + tutorial +] \ No newline at end of file diff --git a/docs/examples/directory.conf b/docs/examples/directory.conf new file mode 100644 index 0000000..3739e65 --- /dev/null +++ b/docs/examples/directory.conf @@ -0,0 +1,5 @@ +laika.title = Examples +laika.navigationOrder = [ + index.md + solutions.md +] \ No newline at end of file diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..c3bd0f4 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,425 @@ +# Working with Files + +Here you will find a curated collection of code examples that show the library in action. + +## Scores + +Imagine we have a file with scores in the following format (`:`): + +```text +michel:21839 +zeta:9929 +thanh:20933 +tonio:12340 +arman:6969 +gabriel:32123 +``` + +We want to read the file, parse the scores, print them to the console and also append new scores to the file. + +In order to perform these operations we first need to create a representation of the score in Scala: + +```scala 3 +case class Score(name: String, score: Int): + def show: String = s"$name:$score" +``` + +And then a function that parses a string into a `Either[Throwable, Score]`: + +```scala 3 +def parseScore(strScore: String): Either[Throwable, Score] = + Either.catchNonFatal( // (1) + strScore.split(':') match + case Array(name, score) => Score(name, score.toInt) + case _ => Score("Cant parse this score", -1) // (2) + ) +``` +The method is going to return a `Left` or a `Right` according to the outcome of the score parsing. The function consists in two steps: + +**(1)** `Either.catchNonFatal` is a function that catches exceptions and wraps them in a `Left` value. + +**(2)** If the score cannot be parsed, we return a default score. + +We can now create our script: + +```scala 3 +for + lines <- path.readLines // (1) + scores <- lines.traverse(parseScore(_).liftTo[IO]) // (2) + _ <- IO(scores.foreach(score => println(score.show))) // (3) + _ <- path.appendLine(Score("daniela", 100).show) // (4) +yield () +``` + +**(1)** We first read every line of the file as part of a list of strings. + +**(2)** Then, we use the `traverse` method to apply an effectful function to every element in the list and then turn the type inside the list, inside out (like a pure `foreach`). We are parsing the scores and converting the `Either` type to an `IO` type (`List[String] => List[Either[Throwable, Score]] => List[IO[Score]] => IO[List[Score]`). + +**(3)** After that, we print every score to the console. + +Finally, we append a new score to the file **(4)** via the `appendLine` method. + +Here is the complete example of the Scores example: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc +import cats.syntax.all.* +import cats.effect.{IO, IOApp} + +import fs2.io.file.Path + +import catscript.syntax.path.* + +object Scores extends IOApp.Simple: + + case class Score(name: String, score: Int): + def show: String = s"$name:$score" + + def parseScore(strScore: String): Either[Throwable, Score] = + Either.catchNonFatal( + case Array(name, score) => Score(name, score.toInt) + case _ => Score("Cant parse this score", -1) + ) + + val path = Path("src/main/resources/scores.txt") + + def run: IO[Unit] = + for + lines <- path.readLines + scores <- lines.traverse(parseScore(_).liftTo[IO]) + _ <- IO(scores.foreach(score => println(score.show))) + _ <- path.appendLine(Score("daniela", 100).show) + yield () + +end Scores +``` + +@:choice(static) + +```scala 3 mdoc:reset +import cats.syntax.all.* +import cats.effect.{IO, IOApp} + +import fs2.io.file.Path + +import catscript.Catscript + +object Scores extends IOApp.Simple: + + case class Score(name: String, score: Int): + def show: String = s"$name:$score" + + def parseScore(strScore: String): Either[Throwable, Score] = + Either.catchNonFatal( + strScore.split(':') match + case Array(name, score) => Score(name, score.toInt) + case _ => Score("Cant parse this score", -1) + ) + + val path = Path("src/main/resources/scores.txt") + + def run: IO[Unit] = + for + lines <- Catscript.readLines(path) + scores <- lines.traverse(parseScore(_).liftTo[IO]) + _ <- IO(scores.foreach(score => println(score.show))) + _ <- Catscript.appendLine(path, Score("daniela", 100).show) + yield () + +end Scores +``` + +@:choice(fs2) + +```scala 3 mdoc:reset +import cats.syntax.all.* +import cats.effect.{IO, IOApp} + +import fs2.Stream +import fs2.io.file.{Files, Path, Flags} + + +object Scores extends IOApp.Simple: + + case class Score(name: String, score: Int): + def show: String = s"$name:$score" + + def parseScore(strScore: String): Either[Throwable, Score] = + Either.catchNonFatal( + strScore.split(':') match + case Array(name, score) => Score(name, score.toInt) + case _ => Score("Cant parse this score", -1) + ) + + val path = Path("src/main/resources/scores.txt") + + def run: IO[Unit] = + Files[IO].readUtf8Lines(path) + .evalMap(parseScore(_).liftTo[IO]) + .evalTap(scores => IO.println(scores.show)) + .last + .flatMap( _ => + Stream.emit(s"\n${Score("daniela", 100).show}") + .through(Files[IO].writeUtf8(path, Flags.Append)) + ) + .compile + .drain + +end Scores +``` + +@:@ + +## Uppercase + +You can also manipulate entire files in one go. In this example, we are going to read a file, convert it to uppercase, and write it to another file. + +As the first thing, we are going to define the locations of the original file and the new file: + +```scala 3 +val path = Path("src/main/resources/quijote.txt") +val upperPath = Path("src/main/resources/quijote_screaming.txt") +``` +We are going to use El Quijote for this example. + +Then, we can easily read the file, perform the transformation, and write it to a new file (we can also overwrite if we use the same path): + +```scala 3 +for + file <- path.read + _ <- upperPath.write(file.toUpperCase) +yield () +``` + +Note that we loaded the whole file as a string, converted it to uppercase, and finally, wrote it to the new file. + +Here, the complete script: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc +import cats.effect.{IO, IOApp} +import fs2.io.file.Path + +import catscript.syntax.path.* + +object Uppercase extends IOApp.Simple: + + val path = Path("src/main/resources/quijote.txt") + val upperPath = Path("src/main/resources/quijote_screaming.txt") + + def run: IO[Unit] = + for + file <- path.read + _ <- upperPath.write(file.toUpperCase) + yield () + +end Uppercase +``` + +@:choice(static) + +```scala 3 mdoc:reset +import cats.effect.{IO, IOApp} +import fs2.io.file.Path + +import catscript.syntax.Catscript + +object Uppercase extends IOApp.Simple: + + val path = Path("src/main/resources/quijote.txt") + val upperPath = Path("src/main/resources/quijote_screaming.txt") + + def run: IO[Unit] = + for + file <- Catscript.read(path) + _ <- Catscript.write(upperPath, file.toUpperCase) + yield () + +end Uppercase +``` + +@:choice(fs2) + +```scala 3 mdoc:reset +import cats.effect.{IO, IOApp} +import fs2.io.file.{Path, Files} + + +object Uppercase extends IOApp.Simple: + + val path = Path("src/main/resources/quijote.txt") + val upperPath = Path("src/main/resources/quijote_screaming.txt") + + def run: IO[Unit] = + Files[IO].readUtf8(path) + .map(_.toUpperCase) + .through(Files[IO].writeUtf8(upperPath)) + .compile + .drain + +end Uppercase +``` + +@:@ + +## Places + +Catscript can handle binary files in custom binary formats since it includes [scodec](https://scodec.org/). In this example, we are going to create a binary file with a list of places. + +We start by defining type representations of the data we are going to work with: + +```scala 3 +case class Place(position: Int, contestant: String) +``` + +Then we are going to use the `scodec` library to create a codec for the `Place` type. + +A first approach would be to create your own custom codec using the combinators of scodec: + +```scala 3 +import scodec.codecs.* + +given placeCodec: Codec[Place] = (uint8 :: utf8).as[Place] +``` + +But since Scala 3, you can also use the `derives` feature of Scala 3 to automatically derive a codec for your data types: + +```scala 3 +case class Place(position: Int, contestant: String) derives Codec +``` +That will automatically create a given instance of `Codec[Place]` for you! + +We can now create a file with a list of places, read it, and print it to the console: + +```scala 3 +for + exists <- path.exists // (1) + _ <- path.createFile.unlessA(exists) // (2) + _ <- path.writeAs[Place](Place(1, "Michael Phelps")) // (3) + place <- path.readAs[Place] // (4) + _ <- IO.println(place) +yield () +``` + +The scripts start by checking the file in the path value exists **(1)**. If it does, we create the file **(2)** using the `unlessA` combinator. It executes the effect if the condition is false, similar of doing a `if exists then IO.unit else path.createFile`. + +Then, when can automatically read and write the `Place` type to the file using the `writeAs` and `readAs` methods (3 and 4). That is thanks to the given codec in scope we created before! It handles the encoding and decoding of the binary file automatically. + +Here is the complete script of the Places example: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc +import cats.syntax.applicative.* +import cats.effect.{IO, IOApp} + +import fs2.io.file.Path + +import scodec.codecs.* +import scodec.Codec + +import catscript.syntax.path.* + +object Place extends IOApp.Simple: + + case class Place(position: Int, contestant: String) derives Codec + + val path = Path("src/main/resources/place.data") + + def run: IO[Unit] = + for + exists <- path.exists + // Equivalent of doing `if (exists) IO.unit else path.createFile` + _ <- path.createFile.unlessA(exists) + _ <- path.writeAs[Place](Place(1, "Michael Phelps")) + place <- path.readAs[Place] + _ <- IO.println(place) + yield () + +end Place +``` + +@:choice(static) + +```scala 3 mdoc +import cats.syntax.applicative.* +import cats.effect.{IO, IOApp} + +import fs2.io.file.Path + +import scodec.codecs.* +import scodec.Codec + +import catscript.syntax.Catscript + +object Place extends IOApp.Simple: + + case class Place(position: Int, contestant: String) derives Codec + + val path = Path("src/main/resources/place.data") + + def run: IO[Unit] = + for + exists <- Catscript.exists(path) + // Equivalent of doing `if (exists) IO.unit else path.createFile` + _ <- Catscript.createFile(path).unlessA(exists) + _ <- Catscript.writeAs[Place](path, Place(1, "Michael Phelps")) + place <- Catscript.readAs[Place](path) + _ <- IO.println(place) + yield () + +end Place +``` + +@:choice(fs2) + +```scala 3 mdoc +import cats.syntax.applicative.* +import cats.effect.{IO, IOApp} + +import fs2.Stream +import fs2.io.file.Path +import fs2.interop.scodec.* + +import scodec.codecs.* +import scodec.Codec + + +object Place extends IOApp.Simple: + + case class Place(position: Int, contestant: String) derives Codec + + val path = Path("src/main/resources/place.data") + + def run: IO[Unit] = + for + exists <- Files[IO].exists(path) + // Equivalent of doing `if (exists) IO.unit else path.createFile` + _ <- Files[IO].createFile(path).whenA(exists) + + _ <- Stream.emit(Place(1, "Michael Phelps")) + .covary[IO] + .through(StreamEncoder.many(placeCodec).toPipeByte) + .through(Files[IO].writeAll(path)) + .compile + .drain + + _ <- Files[IO].readAll(path) + .through(StreamDecoder.many(placeCodec).toPipeByte) + .evalTap(place => IO.println(place)) + .compile + .drain + yield () + +end Place +``` + +@:@ \ No newline at end of file diff --git a/docs/examples/solutions.md b/docs/examples/solutions.md new file mode 100644 index 0000000..f72da4d --- /dev/null +++ b/docs/examples/solutions.md @@ -0,0 +1,117 @@ +# Solutions + +This section offers possible solutions to the documentation's exercises. Refer to them if you get stuck or simply want to compare your approach with another. + +## Reading and printing a file + +```scala mdoc:compile-only +import cats.effect.{IO, IOApp} +import fs2.io.file.Path +import org.typelevel.catscript.syntax.path.* + +object ReadingSolution extends IOApp.Simple: + + val path = Path("testdata/readme.txt") + + def run: IO[Unit] = + for + file <- path.read // This part corresponds more or less to the path.read.flatMap ( ... ) + _ <- IO(println(file)) // And this one is the ... flatMap( file => IO(println(file) ) + yield () + +end ReadingSolution +``` + + +## Writing and modifying the contents of a file + +```scala mdoc:compile-only +import cats.effect.{IO, IOApp} +import fs2.io.file.Path +import org.typelevel.catscript.syntax.path.* + +object WritingSolution extends IOApp.Simple: + + val part1 = Path("books/Dune: Part One") + val part2 = Path("books/Dune: Part Two") + + def run: IO[Unit] = + for + book1 <- part1.read + book2 <- part2.read + _ <- Path("books/Dune: The whole Saga").write(book1 ++ book2) + yield () + +end WritingSolution +``` + +## Working line by line + +```scala mdoc:compile-only +import cats.effect.{IO, IOApp} +import fs2.io.file.Path +import org.typelevel.catscript.syntax.path.* + +object LinesSolution extends IOApp.Simple: + + val noSpace = Path("poems/edgar_allan_poe/no_spaced_dream.txt") + val spaced = Path("poems/edgar_allan_poe/spaced_dream.txt") + + def run: IO[Unit] = + for + poemLines <- noSpace.readLines + _ <- spaced.writeLines(poemLines.map(verse => s"$verse\n")) + yield () + +end LinesSolution +``` + + +## Create a file + +```scala mdoc:compile-only +import cats.effect.IO +import fs2.io.file.Path +import org.typelevel.catscript.syntax.path.* + +extension (path: Path) + def createFileAndDirectories: IO[Unit] = + path.parent match + case Some(dirs) => dirs.createDirectories >> path.fileName.createFile + case None => path.createFile +``` + + +## Deleting here and there + +```scala mdoc:compile-only +import cats.syntax.all.* +import cats.effect.IO +import fs2.io.file.Path +import org.typelevel.catscript.syntax.path.* + +extension (path: Path) + def deleteIfChubby(threshold: Long): IO[Boolean] = + for + size <- path.size + isFat = (size >= threshold).pure[IO] + deleted <- isFat.ifM(path.deleteIfExists, false.pure[IO]) + yield deleted +``` + +## Using temporary files + +```scala mdoc:compile-only +import cats.effect.{IO, Resource} +import fs2.io.file.Path +import org.typelevel.catscript.syntax.path.* + +def makeTempFile: Resource[IO, Path] = + Resource.make(createTempFile)(p => p.deleteIfExists.void) + +def makeTempDirectory: Resource[IO, Path] = + Resource.make(createTempDirectory): tempDir => + tempDir.deleteRecursively.recoverWith: + case _: java.nio.file.NoSuchFileException => IO.unit + +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index fd01005..8fc7589 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,36 @@ -# Catscript +# Catscript 😸 +**Making scripts in pure Scala much easier!** -Catscript +## Getting Started + +You can use Catscript in a new or existing Scala 2.13.x or 3.x project by adding it to your `build.sbt` file: + +```scala +libraryDependencies ++= Seq( + "org.typelevel" %% "catscript" % "@VERSION@" +) +``` + +## Example +Catscript is a library to perform common script operations such as working with processes and files while maintaining referential transparency! + +```scala 3 mdoc:reset +import cats.effect.{IO, IOApp, ExitCode} + +import catscript.* +import catscript.syntax.path.* + +object Main extends IOApp: + + def run(args: List[String]): IO[ExitCode] = + for + home <- userHome + config = home / ".catscript" / "config.conf" + _ <- config.createFile + _ <- config.write("scripting.made.easy = true") + newconfig <- config.read + _ <- IO.println(s"Loading config: $newconfig") + yield ExitCode.Success + +end Main +``` \ No newline at end of file diff --git a/docs/introduction/directory.conf b/docs/introduction/directory.conf new file mode 100644 index 0000000..48fe652 --- /dev/null +++ b/docs/introduction/directory.conf @@ -0,0 +1,6 @@ +laika.title = Introduction +laika.navigationOrder = [ + index.md + imports.md + whats_io.md +] \ No newline at end of file diff --git a/docs/introduction/index.md b/docs/introduction/index.md new file mode 100644 index 0000000..f67bc39 --- /dev/null +++ b/docs/introduction/index.md @@ -0,0 +1,195 @@ +# First steps + +## Which API should I use? + +In Catscript, you have two different ways of doing things: + +- First, if you prefer a more concise syntax, use the extension methods (e.g. `path.read()`) by importing the `catscript.syntax.path.*` package. + +- If you prefer calling static methods, use direct method calls on the `Catscript` object (e.g., `Catscript.read(path)`). You can also import all the functions inside the `catscript.Catscript` package if you don't want to call the `Catscript` object every time (e.g., `read(path)`). + +In this documentation we'll call the methods on the `Catscript` objects in the static variant to differentiate them from the extension ones. + +@:select(api-style) + +@:choice(syntax) + +```scala 3 +import catscript.syntax.all.* + +val path = Path("data/test.txt") + +for + file <- path.read + _ <- path.append("I'll place this here.") +yield () +``` + +@:choice(static) + +```scala 3 +import catscript.Catscript + +val path = Path("data/test.txt") + +for + file <- Catscript.read(path) + _ <- Catscript.append(path, "I'll place this here.") +yield () +``` + +@:@ + +Which one you should use really depends on your preferences and choices; if you like the method style of calling functions directly on the objects, stick to the extension methods syntax, if you rather prefer a more Haskell-like style, stick to the static object calls! + +## Imports + +If you just want to start scripting right away, importing `catscript.*` will do the trick; it imports all the extension methods and functionality you need, such as types and functions, to start working right away. + +But if you want more concise functionality, the `catscript.syntax.path.*` will only import the extension methods. + +For the static methods, the `catscript.Catscript` will provide the functions to work with files, if that is your preferred style. + + +## Talking about computations + +Throughout this library you will often see the word calculation, but what is it? A computation is a well-defined, step-by-step work that calculates a value when evaluated (and can also perform side effects along the way). For example, this is a computation: + +```scala +object ProgramOne: + val computationOne = + val a = 2 + val b = 3 + println(s"a: $a, b: $b") + a + 2 * b +``` + +In this case, the program `ProgramOne` has a computation that calculates 2 + 2 * 3 and logs some values to the console. When this computation is evaluated (for example, by a main function), it will compute the value 8. + +This may seem trivial, but someone has to put the nail before hammering. + +## What is this `IO` thing? + +You may be wondering at this point, what is this `IO` thing that appears at the end of the functions? Well, that's the [IO monad](https://typelevel.org/cats-effect/docs/2.x/datatypes/io) of Cats Effect; the concept is much more extensive than we can explain on this page, but basically it is a type that allows us to suspend side effects so that they do not run instantly: + +```scala mdoc +import cats.effect.IO + +/* Will print to de console! */ +val printingHello = println("Hello newbies!") + +/* Will not do anything (yet) */ +val suspendingHello = IO(println("Hello newbies!")) +``` + +To actually run the computation, you have two options, the first one (and not recommended) is to call the `unsafeRunSync()` function at the very end of the program: + +```scala mdoc +import cats.effect.unsafe.implicits.global // Imports the runtime that executes the IO monad + +suspendingHello.unsafeRunSync() +``` + +But this is not the usual way: the usual way is to passing it to the `run` function (similar to the main method but for `IO`). To that, you have to extend your application's main object with `IOApp`: + +```scala mdoc:silent +import cats.effect.IOApp + +object Main extends IOApp.Simple: + + def run: IO[Unit] = suspendingHello + +end Main +``` + +Either way, the `IO` will be executed and all the computation will be evaluated. + +But why's that useful? Well, one of the advantages is referential transparency, and that basically means that we can replace the code wherever it is referenced and expect the same results every time: + +```scala mdoc +val num = 2 + +(num + num) == (2 + 2) +``` + +It may seem trivial, but that's not always the case: + +```scala mdoc +val salute = + println("Hellow, ") + "Hellow, " + +val meow = + println("meow.") + "meow" + +def result1: String = salute + meow +``` + +If referential transparency exists in your program, replacing `println("Hellow, "); "Hellow, "` in `salute` should fire the print to the console two times, same with `meow`, which is not the case: + +```scala mdoc +def result2: String = { println("Hellow, "); "Hellow, " } + { println("meow"); "meow" } + +result1 +result2 +``` + +As you can see, only the `result1` printed twice, even though we replaced the exact same definitions with the implementations. With `IO`, we can solve this by delaying the print to the stout: + +```scala mdoc +val saluteIO = IO: + println("Hellow, ") + "Hellow, " + +val meowIO = IO: + println("meow.") + "meow." + +def result1IO: IO[String] = + for + hello <- saluteIO + meow <- meowIO + yield hello + meow + +def result2IO: IO[String] = + for + hello <- IO { println("Hellow, "); "Hellow, " } + meow <- IO { println("meow"); "meow " } + yield hello + meow + +result1IO.unsafeRunSync() +result2IO.unsafeRunSync() + +``` +Now both results are the same, an behave exactly the same! + +Here's a [good explanation](https://blog.rockthejvm.com/referential-transparency/) about the benefits of referential transparency. + + +Another benefit is gaining explicit control over code execution. By encapsulating computations within the `IO` monad, your programs become blueprints rather than direct statements. This gives you the ability to decide precisely when to execute those statements. + + +## Weird `>>` and `>>=` operators, what are those? + +While reading the documentation of this library, you may come across some strange operator like `>>`. This is convenient syntax sugar for some fairly common methods! + +You can import them using the syntax package in cats, like this: + +```scala mdoc +import cats.syntax.all.* +``` + +For instance, you may seen something like this: + +```scala mdoc:compile-only +IO("The result is: 42") >>= IO.println +``` +That is just an alias for `flatMap`, so it's like writing `IO("The result is: 42").flatMap(IO.println(_))`, but without the added boilerplate. This use is more common in languages like Haskell, but we'll use it in the documentation to simplify things a bit! + + +The `>>` is even simpler: +```scala mdoc:compile-only +IO.println("Loggin here... ") >> IO("Returning this string!") +``` +This is used for concatenating monads that you do not care about the result of the computation, just like doing `IO.println("Loggin here...").flatMap( _ => IO("Returning this string!"))`. When to use it? In the example from above, `IO.println` computes a Unit `()`, so you can't do much with it anyway, so the `>>` operator comes in handy! diff --git a/docs/tutorial/creating_cli.md b/docs/tutorial/creating_cli.md new file mode 100644 index 0000000..8b4f8e0 --- /dev/null +++ b/docs/tutorial/creating_cli.md @@ -0,0 +1,624 @@ +# Creating a Contact Manager CLI + +Moving on from the basics, in this tutorial, we will create a simple CLI application that manages contacts. The application will allow users to add, list, update, and delete contacts. + + +## Basic Idea +We want a CLI application that stores the contacts in a file on your system and allows you to perform CRUD operations on the contacts. The application will look something like this: + + +```bash +$ cm add +> Enter username: contact_username +> First Name: contact_name +> Last Name: contact_name +> Phone: contact_phone +> Email: contact_email +``` + +```bash +$ cm get +> ----- contact_username ----- +> First Name: contact_name +> Last Name: contact_name +> Phone: contact_phone +> Email: contact_email +``` + +```bash +$ cm update contact_username --last-name new_contact_name +> Contact contact_username updated successfully +``` + +Now that we know what we want to build, let us start by creating the project structure. + +## Project Structure +For this project, we will use the following project structure: + +``` +examples/ +└── src + └── main + └── scala + └── contacts + β”œβ”€β”€ app + β”œβ”€β”€ cli + β”œβ”€β”€ core + └── domain +``` + +- `app`: This package will contain the main entry point of the application. +- `cli`: This package will contain the CLI logic such as prompt parsing and command execution. +- `core`: This package will contain the business logic of the application, here is where we will use Catscript! +- `domain`: This package will contain important business logic (mostly types) that `app`, `cli` and `core` will use. + +Let us first start by defining our domain. + +## Domain +Our domain will be straightforward, we will have a `Contact` case class that will represent a contact. + +```scala +//src/main/catscript/contacts/domain/contact.scala +type Username = String +type Name = String +type PhoneNumber = String +type Email = String + +case class Contact( + username: Username, + firstName: Name, + lastName: Name, + phoneNumber: PhoneNumber, + email: Email +) +``` +The type aliases are great, not only for added readability, but they also provide a way of not accidentally passing the wrong argument to the function. You can also use refined types provided by libraries like [Iron](https://github.com/Iltotore/iron), but for the sake of simplicity, we will use plain strings. + +We will also need a function to display the contact in a nice format. + +```scala 3 +//src/main/catscript/contacts/domain/contact.scala +case class Contact( ... ): + def show: String = s""" + |----- $username ----- + | + |First Name: $firstName + |Last Name: $lastName + |Phone: $phoneNumber + |Email: $email + """.stripMargin +``` + +We will also define a custom error type that gets thrown when adding an already existing contact. + + +```scala 3 +//src/main/catscript/contacts/domain/contact.scala +case class ContactFound(username: Username) extends NoStackTrace +``` + +This will make `ContactFound` a subtype of `Throwable` and will allow us to propagate the error in the `IO` monad and handle it whenever we want. + +The next step is defining the different commands that our CLI will support. + +```scala 3 +//src/main/catscript/contacts/domain/argument.scala +enum CliCommand: + case AddContact + case RemoveContact(username: Username) + case SearchId(username: Username) + case SearchName(name: Name) + case SearchEmail(email: Email) + case SearchNumber(number: PhoneNumber) + case UpdateContact(username: Username, options: List[Flag]) + case ViewAll + case Help +``` + +We will also need a `Flag` type to represent the different options that we can update in a contact. + +```scala 3 +//src/main/catscript/contacts/domain/flag.scala +enum Flag: + case FirstNameFlag(firstName: String) + case LastNameFlag(lastName: String) + case PhoneNumberFlag(phoneNumber: String) + case EmailFlag(email: String) + case UnknownFlag(flag: String) +``` + +Now that we have our domain defined, let us move to the core of our application. + +## Core business logic +In this section, we will define the core business logic of our application. We will define a `ContactManager` algebra that will contain all the logic to manage contacts: + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala` +trait ContactManager: + + // Save a new contact to the contact list + def addContact(contact: Contact): IO[Username] + + // Remove a contact from the contact list using the username + def removeContact(username: Username): IO[Unit] + + /** + * Search for a contact using different things like username, + * name, email, or phone number. It will return a list with + * the matching results, or an empty list if none was found. + * + * Searching by id should return only one result, se we use + * an `Option` there. + */ + def searchId(username: Username): IO[Option[Contact]] + def searchName(name: Name): IO[List[Contact]] + def searchEmail(email: Email): IO[List[Contact]] + def searchNumber(number: PhoneNumber): IO[List[Contact]] + + // Lists all the saved contacts + def getAll: IO[List[Contact]] + + /** + * Update a contact using a `modify` function. For example, + * `updateContact("username")(contact β‡’ contact.copy(firstName = "new name"))` + */ + def updateContact(username: String)(modify: Contact => Contact): IO[Contact] + +end ContactManager +``` + +Now that we have our algebra defined, let us implement it using Catscript. + +For that, it's common to implement the interface (or in Scala argot, the *algebra*) in the companion object of the trait: + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala +object ContactManager: + + def apply(bookPath: Path): ContactManager = new ContactManager: + override def addContact(contact: Contact): IO[Username] = ??? + override def removeContact(username: Username): IO[Unit] = ??? + override def searchId(username: Username): IO[Option[Contact]] = ??? + override def searchName(name: Name): IO[List[Contact]] = ??? + override def searchEmail(email: Email): IO[List[Contact]] = ??? + override def searchNumber(number: PhoneNumber): IO[List[Contact]] = ??? + override def getAll: IO[List[Contact]] = ??? + override def updateContact( + username: String + )(modify: Contact => Contact): IO[Contact] = ??? +``` +As a dependency of the implementation, we will use the path to the file where we will store the contacts. Also, the `apply` function will let us call it using the class name! + +The first function will add a contact to the contact list. But first, we need to define a couple of things: + +The fist one will be the encoding of the `Contact` in the file. We’re going to go with a format like this: + +```text +|||| +``` +But of course, you can use any format you want, like JSON, CSV, etc. + +Once we defined the encoding, we need a function to parse the file representation to our type in Scala: + + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala +def apply(bookPath: Path): ContactManager = new ContactManager: + + private def parseContact(contact: String): IO[Contact] = + contact.split('|') match + case Array(id, firstName, lastName, phoneNumber, email) => + Contact(id, firstName, lastName, phoneNumber, email).pure[IO] + case _ => + new Exception(s"Invalid contact format: $contact") + .raiseError[IO, Contact] +``` + +Next, a function to encode the contact to a string: + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala + private def encodeContact(contact: Contact): String = + s"${contact.username}|${contact.firstName}|${contact.lastName}|${contact.phoneNumber}|${contact.email}" +``` + +Finally, a helper function to help us save the contacts in memory: +```scala 3 + private def saveContacts(contacts: List[Contact]): IO[Unit] = + bookPath.writeLines(contacts.map(encodeContact)) +``` + +Now we will start with the `getAll` function. This is because our contact manager will work by first loading and parsing all the contacts so that we can perform operations on them: + +```scala 3 +override def getAll: IO[List[Contact]] = + for + lines <- bookPath.readLines + contacts <- lines.traverse(parseContact) + yield contacts +``` + +We first load all the contacts in memory using our `readLines` function and then parse them into `Contact`s. We're going to use `traverse` to convert our `List[String]` into a `IO[List[Contact]]`. Why? Because the `parseContacts` function returns an `IO[Contact]`, so it suits perfectly for our use case. + +We can now implement the `addContact` function: + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala +override def addContact(contact: Contact): IO[Username] = + for + contacts <- getAll // (1) + _ <- IO(contacts.contains(contact)).ifM( // (2) + ContactFound(contact.username).raiseError[IO, Unit], + saveContacts(contact :: contacts) + ) + yield contact.username +``` + +First, we read all of our contacts, line by line using the `ContactManager.getAll` method (1). Then, we check if the contact already exists in the file checking if the username is contained (2). If it does, we raise an error using the `raiseError` method. If it doesn't, we save it to memory using our `saveContacts` methods. This is done with the `ifM`; this method checks for a boolean value in an `IO` and executes one of the two declared effects depending on it. + +Now that we have the `addContact` function implemented, we can move to the `removeContact` function: + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala +override def removeContact(username: Username): IO[Unit] = + for + contacts <- getAll + filteredContacts = contacts.filterNot(_.username === username) + _ <- saveContacts(filteredContacts) + yield () +``` + +Here we load all the contacts from the file, and then we filter the ones that don't match the username we want to remove. Finally, we write the filtered contacts back to the file. + +Now onto the search functionality. The first one is the `searchId` function: + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala +override def searchUsername(username: Username): IO[Option[Contact]] = + getAll.map(contacts => contacts.find(_.username === username)) +``` + +Again we load all the contacts from the file, and then we find the contact that matches the username we want to search using the `find` functions, it returns an `Option` whenever it found something or not. + +The next three functions are very similar, so we will only show one of them, the `searchName` function: + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala +override def searchName(name: Name): IO[List[Contact]] = + getAll.map(contacts => + contacts.filter(c => c.firstName === name || c.lastName === name) + ) +``` + +The first part is similar to the previous functions, we load all the contacts from the file, and then we filter the ones that contain the name (or field) we want to search. + + +The last function we need to implement is the `updateContact` function. Be aware that this is the most complex function to implement, so we will break it down into smaller parts. + +```scala 3 +//src/main/catscript/contacts/core/ContactManager.scala +override def updateContact( + username: Username +)(modify: Contact => Contact): IO[Contact] = + for + oldContact <- searchUsername(username) match // (1) + case None => ContactNotFound(username).raiseError[IO, Contact] + case Some(contact) => contact.pure[IO] + + updatedContacts = modify(oldContact) :: contacts.filterNot(_ == oldContact) // (2) + _ <- saveContacts(updatedContacts) + yield updatedContact + + +``` + + +1. First, we'll find if the Contact we want to modify exists. If it doesn't, we raise a `ContactNotFound` error. + +2. In case the contact exists, we are going to apply to it the transform function and add it to the loaded list. While doing that, we delete the old contact via the `filterNot` method. + +3. Finally, we save the new contact list to the memory. + +Now that we're done with the core of our application, we can move to the CLI part. + +## CLI +In this section, we will define our application's command line interface: it will accept commands and print the output to stdout. + +It makes little sense to define the behaviour of the CLI in terms of an interface, as there are only a few ways of interacting with the user via a console. That's why we will just wrap these functionalities in some functions in a `Cli` object: + +```scala 3 +//src/main/catscript/contacts/cli/Cli.scala +object Cli: + def addCommand ... +``` + +One way of passing the `ContactManager` dependency to these functions is to pass it as an argument. However, it can be tedious to do this every time we want to use the functionality of our `Cli`, that's why Scala has an option declare implicit parameters via the `given`/`using` clauses. We just need to use the `using` keyword in front of any function's argument and as long as there is a given instance in scope at call site for every `using` clause, that value will be passed automatically. This feature is called [context parameters](https://docs.scala-lang.org/scala3/book/ca-context-parameters.html). + +That said, the first function is going to be the `addCommand`. The idea is to ask the user the contact's information one step at a time and then add it to the contact list: + +```scala 3 +//src/main/catscript/contacts/cli/Cli.scala +def addCommand(using cm: ContactManager): IO[Unit] = + for + username <- IO.println("Enter the username: ") >> IO.readLine + firstName <- IO.println("Enter the first name: ") >> IO.readLine + lastName <- IO.println("Enter the last name: ") >> IO.readLine + phoneNumber <- IO.println("Enter the phone number: ") >> IO.readLine + email <- IO.println("Enter the email: ") >> IO.readLine + + contact = Contact(username, firstName, lastName, phoneNumber, email) + + _ <- cm.addContact(contact) + .flatMap(username => IO.println(s"Contact $username added")) + .handleErrorWith: + case ContactFound(username) => + IO.println(s"Contact $username already exists") + case e => + IO.println(s"An error occurred: \n${e.printStackTrace()}") + + yield () +``` + +The first five `IO.readLine` calls in the `for` comprehension ask for the contact's detail, we then attempt to create the `Contact` object with the information provided calling `addContact` method of the `ContactManager` and handle the errors. If the contact already exists, we print a message to the user, if another error occurs, we print the stack trace of the error to see what went wrong. + +Now the `removeCommand` function: + +```scala 3 +//src/main/catscript/contacts/cli/Cli.scala +def removeCommand(username: Username)(using cm: ContactManager): IO[Unit] = + cm.removeContact(username) >> IO.println(s"Contact $username removed") +``` +It simply calls the `removeContact` function and prints a message to the user. The function doesn't check if the contact exists and will delete any matching username anyway, but you can add this logic if you want. + +The `searchIdCommand` function is also straight forward, matches the `Options` returned by the `searchId` method and prints the user information if it exists: + +```scala 3 +//src/main/catscript/contacts/cli/Cli.scala +def searchIdCommand(username: Username)(using cm: ContactManager): IO[Unit] = + for + contact <- cm.searchId(username) + _ <- contact match + case Some(c) => IO.println(c.show) + case None => IO.println(s"Contact $username not found") + yield () +``` + +All the `searchX` variants are fairly similar so we'll show just one of them: + +```scala 3 +//src/main/catscript/contacts/cli/Cli.scala +def searchEmailCommand(email: Email)(using cm: ContactManager): IO[Unit] = + for { + contacts <- cm.searchEmail(email) + _ <- contacts.traverse_(c => IO.println(c.show)) +} yield () +``` + +It also uses the `traverse_` method, but with a little variant that optimises it a bit. It takes as a parameter a function from `A => IO[Unit]`, and it will execute the effect for each element of the collection, but it will discard the result of the computation. +`searchEmailCommand` uses the `traverse_` method: a little variant of `traverse` that takes a `A => IO[Unit]` function and then discards the result. +Last but not least, the `updateCommand` function. + +We take as a first parameter the username of the contact we want to update in the database, so we can search for its existence. + +```scala 3 +//src/main/catscript/contacts/cli/Cli.scala +def updateCommand(username: Username, options: List[Flag])( + using cm: ContactManager +): IO[Unit] = + cm.updateContact(username): prev => + options.foldLeft(prev): (acc, flag) => // (1) + flag match + case FirstNameFlag(name) => acc.copy(firstName = name) // (2) + case LastNameFlag(name) => acc.copy(lastName = name) + case PhoneNumberFlag(number) => acc.copy(phoneNumber = number) + case EmailFlag(email) => acc.copy(email = email) + case UnknownFlag(_) => acc + + .flatMap(c => IO.println(s"Updated contact ${c.username}")) // (3) + .handleErrorWith: + case ContactNotFound(username) => + IO.println(s"Contact $username not found") + case e => + IO.println(s"An error occurred: \n${e.printStackTrace()}") +``` + +This is also quite a bit of code, but let's break it down: + + +First, we call the `updateContact` method of the `ContactManager` and pass the function that will modify the contact. To do this, we're going to reduce the list of flags using `foldLeft`. This function takes an initial value (the contact we want to update) and a function describing how to update the initial value (`acc`) with each one of the flags in turn (`flag`) (1). Where, we just use the `copy` methods and pattern match the flags to update the contact (2). After that, we print a message to the user saying that the contact was updated successfully (3). If the contact is not found, we print a message to the user saying that. + +```scala 3 +//src/main/catscript/contacts/cli/Cli.scala +def helpCommand: IO[Unit] = + IO.println: + s""" + |Usage: contacts [command] + | + |Commands: + | add + | remove + | search id + | search name + | search email + | search number + | list + | update [flags] + | help + | + |Flags (for update command): + | --first-name + | --last-name + | --phone-number + | --email + | + |""".stripMargin +``` + +That was tough, congratulations on making it so far! We can now move to the last part of our application. + +## Prompt +The last piece that we need to create is the prompt parsing, that will parse user submitted information into commands that our `Cli` can handle. + +We strongly suggest you to use a command line parsing library, like [Decline](https://ben.kirw.in/decline/) but for the sake of simplicity, we'll demonstrate how to create it without the need of an external dependency. + +The first thing we'll do is creating a `parsePrompt` function that will pattern match over the user input and, according to the value will return the appropriate `CliCommand`: + +```scala 3 +//src/main/catscript/contacts/cli/Prompt.scala +def parsePrompt(args: List[String]): CliCommand = + args match + case "add" :: Nil => AddContact + case "remove" :: username :: Nil => RemoveContact(username) + case "search" :: "id" :: username :: Nil => SearchId(username) + case "search" :: "name" :: name :: Nil => SearchName(name) + case "search" :: "email" :: email :: Nil => SearchEmail(email) + case "search" :: "number" :: number :: Nil => SearchNumber(number) + case "list" :: _ => ViewAll + case "update" :: username :: options => + UpdateContact(username, parseUpdateFlags(options)) + case Nil => Help + case _ => Help +end parsePrompt +``` + + +We also created a `parseUpdateFlags` function that creates a list of flags that can change the updating behaviour using a similar pattern: + +```scala 3 +//src/main/catscript/contacts/cli/Prompt.scala` +private def parseUpdateFlags(options: List[String]): List[Flag] = + + @tailrec + def tailParse(remaining: List[String], acc: List[Flag]): List[Flag] = + remaining match + + case Nil => acc + + case "--first-name" :: firstName :: tail => + tailParse(tail, FirstNameFlag(firstName) :: acc) + + case "--last-name" :: lastName :: tail => + tailParse(tail, LastNameFlag(lastName) :: acc) + + case "--phone-number" :: phoneNumber :: tail => + tailParse(tail, PhoneNumberFlag(phoneNumber) :: acc) + + case "--email" :: email :: tail => + tailParse(tail, EmailFlag(email) :: acc) + + case flag :: _ => List(UnknownFlag(flag)) + + + tailParse(options, Nil) + +end parseUpdateFlags +``` + +Because the update flags can be unordered, we case use a recursive function to match each flag and its value and then create a list of `Flag`. + +## Main + +Now that we have all the required components we can define the entry point of our application. + +The first thing that we need to do is create an initial function that that will create our in-memory database (located at `~/.catscript/contacts.data`) if needed: + +```scala 3 +//src/main/catscript/contacts/app/App.scala +private val getOrCreateBookPath: IO[Path] = + for + home <- userHome + dir = home / ".catscript" + path = dir / "contacts.data" + exists <- path.exists + _ <- dir.createDirectories.unlessA(exists) + _ <- path.createFile.unlessA(exists) + yield path +``` + + +We can then create our `given` instance of `ContactManager`: + +```scala 3 +getOrCreateBookPath + .map(ContactManager(_)) + .flatMap: + case given ContactManager +``` + +With that, our main (`run`) function will be like this: + +```scala 3 +//src/main/catscript/contacts/app/App.scala + .flatMap: + case given ContactManager => + + Prompt.parsePrompt(args) match + case Help => Cli.helpCommand + case AddContact => Cli.addCommand + case RemoveContact(username) => Cli.removeCommand(username) + case SearchId(username) => Cli.searchUsernameCommand(username) + case SearchName(name) => Cli.searchNameCommand(name) + case SearchEmail(email) => Cli.searchEmailCommand(email) + case SearchNumber(number) => Cli.searchNumberCommand(number) + case ViewAll => Cli.viewAllCommand + case UpdateContact(username, flags) => + Cli.updateCommand(username, flags) + + .as(ExitCode.Success) +``` + +This is what the final code looks like: + +```scala 3 +//src/main/catscript/contacts/app/App.scala +object App extends IOApp: + + private val getOrCreateBookPath: IO[Path] = + for + home <- userHome + dir = home / ".catscript" + path = dir / "contacts.data" + exists <- path.exists + _ <- dir.createDirectories.unlessA(exists) + _ <- path.createFile.unlessA(exists) + yield path + + def run(args: List[String]): IO[ExitCode] = getOrCreateBookPath + .map(ContactManager(_)) + .flatMap: + case given ContactManager => + + Prompt.parsePrompt(args) match + case Help => Cli.helpCommand + case AddContact => Cli.addCommand + case RemoveContact(username) => Cli.removeCommand(username) + case SearchId(username) => Cli.searchUsernameCommand(username) + case SearchName(name) => Cli.searchNameCommand(name) + case SearchEmail(email) => Cli.searchEmailCommand(email) + case SearchNumber(number) => Cli.searchNumberCommand(number) + case ViewAll => Cli.viewAllCommand + case UpdateContact(username, flags) => + Cli.updateCommand(username, flags) + + .as(ExitCode.Success) + +``` + +And that's it! + +We’ve created a simple CLI application that manages contacts using Catscript and Cats Effect. +We hope you enjoyed this tutorial and learned a lot about how to use Catscript in a real-world application. + +### Exercise +We used a hard-coded path to store our contacts. + +But what if we want to change the path to another location? +Or maybe have multiple locations with different contacts? + +Try to modify the application, so the user can initialise and change the location of a contact book on a specific path and then use that path to store the contacts. + +To do that, you can create two new commands, `init` and `change` that will create a new contact book and change the current contact book, respectively. + +You can also create new domain representations of that book, like `ContactBook(path: Path, contacts: List[Contacts])` and modify the `ContactManager` to use that representation. + +Good luck! \ No newline at end of file diff --git a/docs/tutorial/directory.conf b/docs/tutorial/directory.conf new file mode 100644 index 0000000..ad7dca0 --- /dev/null +++ b/docs/tutorial/directory.conf @@ -0,0 +1,7 @@ +laika.title = Tutorial +laika.navigationOrder = [ + path.md + reading_writing.md + file_handling.md + creating_cli.md +] \ No newline at end of file diff --git a/docs/tutorial/file_handling.md b/docs/tutorial/file_handling.md new file mode 100644 index 0000000..5acd0f5 --- /dev/null +++ b/docs/tutorial/file_handling.md @@ -0,0 +1,473 @@ +# File Handling + +A scripting library does not only contain reading and writing operations, that is why we also include methods for manipulating files such as creations, deletions, permissions and others. + +## Create a file + +You may want to create a file without immediately writing on it. For example, when you want to modify the permissions first or let another process/fiber handle it instead. For such and more reasons, you can create a file or directory using the `createFile` and `createDirectory` functions. + +It is generally possible to create empty files and directories using the `createFile` and `createDirectory` functions: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Creating extends IOApp.Simple: + + val filePath = Path("path/to/your/desired/creation/NiceScript.scala") + + def run: IO[Unit] = + for + _ <- filePath.createFile + created <- filePath.exists + _ <- IO.println(s"File created? $created") + yield () + +end Creating +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Creating extends IOApp.Simple: + + val filePath = Path("path/to/your/desired/creation/NiceScript.scala") + + def run: IO[Unit] = + for + _ <- Catscript.createFile(filePath) + created <- Catscript.exists(filePath) + _ <- IO.println(s"File created? $created") + yield () + +end Creating +``` + +@:@ + +Here, we are first creating the file using the `createFile` method and then checking its existent with the `exists` method. + +**Important:** The `createFile` and `createDirectory` methods will only work if the parent directory already exists, and fail otherwise. The `createDirectories` method will recursively create the directories instead: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Creating extends IOApp.Simple: + + val emptyDirectories = Path("create/me/first") + + def run: IO[Unit] = + emptyDirectories.createDirectories >> Path("now_i_can_be_created.fs").createFile + +end Creating +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Creating extends IOApp.Simple: + + val emptyDirectories = Path("create/me/first") + + def run: IO[Unit] = + Catscript.createDirectories(emptyDirectories) >> + Catscript.createFile(Path("now_i_can_be_created.fs")) + +end Creating +``` + +@:@ + +### Exercise + +For this exercise, write a function that creates a file in a (deeply) nested directory. The directories should be created if they don't exist. + +@:select(api-style) + +@:choice(syntax) + +```scala +extension (path: Path) def createFileAndDirectories: IO[Unit] = ??? +``` + +@:choice(static) + +```scala +def createFileAndDirectories(path: Path): IO[Unit] = ??? +``` + +@:@ + +[See possible implementation](../examples/solutions.md#create-a-file) + + +## Deleting here and there + +Creating is just the first part. Because memory is not infinite, you may also want to delete a file on your system. + +Deleting a file is as easy as using the `delete` method: + + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val annoyingFile = Path("desktop/extend_your_car_warranty_now.ad") + + def run: IO[Unit] = + for + exists <- annoyingFile.exists + _ <- if exists then annoyingFile.delete + else IO.unit + yield () + +end Deleting +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val annoyingFile = Path("desktop/extend_your_car_warranty_now.ad") + + def run: IO[Unit] = + for + exists <- Catscript.exists(annoyingFile) + _ <- if exists then Catscript.delete(annoyingFile) + else IO.unit + yield () + +end Deleting +``` + +@:@ + +Note that we are first checking if the file exists before deleting it, this is because trying to delete a file that does not exist will result in an error. To avoid this error, you have two options. One is using the [`whenA` combinator](https://github.com/typelevel/cats/blob/main/core/src/main/scala/cats/Applicative.scala#L263) from [Applicative](https://typelevel.org/cats/typeclasses/applicative.html) importing `cats.syntax.applicative.*`: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.syntax.applicative.* // You can instead import cats.syntax.all.* ! +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val annoyingFile = Path("desktop/extend_your_car_warranty_now.ad") + + def run: IO[Unit] = + for + exists <- annoyingFile.exists + _ <- annoyingFile.delete.whenA(exists) + yield () + +end Deleting +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.syntax.applicative.* // You can instead import cats.syntax.all.* ! +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val annoyingFile = Path("desktop/extend_your_car_warranty_now.ad") + + def run: IO[Unit] = + for + exists <- Catscript.exists(annoyingFile) + _ <- Catscript.delete(annoyingFile).whenA(exists) + yield () + +end Deleting +``` + +@:@ + +Or even better, use the convenience method `deleteIfExists`: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val annoyingFile = Path("desktop/extend_your_car_warranty_now.ad") + + def run: IO[Unit] = + for + deleted <- annoyingFile.deleteIfExists + _ <- IO.println(s"Are they reaching out? $deleted") + yield () + +end Deleting +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val annoyingFile = Path("desktop/extend_your_car_warranty_now.ad") + + def run: IO[Unit] = + for + deleted <- Catscript.deleteIfExists(annoyingFile) + _ <- IO.println(s"Are they reaching out? $deleted") + yield () + +end Deleting +``` + +@:@ + +This will return a boolean indicating whether the file and directories have been deleted. + +Finally, you may want to delete not one but multiple files and directories, here is when the `deleteDirectorires` comes handy, as it will delete all the files and directories recursively (similar to `rm -r`): + +**Before:** + +``` +/My files +β”œβ”€β”€ /non empty folder +β”‚Β Β  β”œβ”€β”€ 3751c91b_2024-06-16_7.csv +β”‚Β Β  β”œβ”€β”€ Screenshot 2024-06-16 210523.png +β”‚Β Β  β”œβ”€β”€ /downloaded +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ /on_internet +β”‚Β Β  β”‚Β Β  β”‚Β Β  └── Unconfirmed 379466.crdownload +β”‚Β Β  β”‚Β Β  └── ubuntu-24.04-desktop-amd64.iso +β”‚Β Β  └── /spark +β”‚Β Β  β”œβ”€β”€ output0-part-r-00000.nodes +β”‚Β Β  └── output1-part-r-00000.nodes +β”‚Β  +β”œβ”€β”€ /dont delete +β”‚Β  +``` + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val nonEmptyFolder = Path("downloads/non empty folder") + + def run: IO[Unit] = nonEmptyFolder.deleteRecursively + +end Deleting +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Deleting extends IOApp.Simple: + + val nonEmptyFolder = Path("downloads/non empty folder") + + def run: IO[Unit] = Catscript.deleteRecursively(nonEmptyFolder) + +end Deleting +``` + +@:@ + +**After:** + +``` +/My files +β”œβ”€β”€ /dont delete +β”‚Β Β  +``` + +### Exercise + +You are tired of people abusing the FTP server to upload enormous files, so you decide to implement a method that checks if a file exceeds a certain size, and if so, automatically delete it (hint: check the `size` method). The method must return `true` if the file has been deleted, `false` otherwise. + + +@:select(api-style) + +@:choice(syntax) + +```scala +extension (path: Path) def deleteIfChubby(threshold: Long): IO[Boolean] = ??? +``` + +@:choice(static) + +```scala +def deleteIfChubby(path: Path, threshold: Long): IO[Boolean] = ??? +``` + +@:@ + +[See possible implementation](../examples/solutions.md#deleting-here-and-there) + +## Using temporary files + +Maybe you do not want to manually delete a file after its use. This is where temporary files come in to play, as they are deleted automatically. + +To create temporary files, you have two options, one is to make Cats Effect automatically handle their lifecycle with the `withTempFile` and `withTempDirectory` methods (useful when you want the files deleted right away), or, if you rather prefer the operating system to take hands in its lifecycle, you can use the `createTempFile` and `createTempDirectory` variants (suitable if you do not care if the files are deleted immediately). + +The former takes as a parameter a function that describes how you want to use the file, like this: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import cats.syntax.all.* +import cats.effect.{IO, IOApp} + +object Temporary extends IOApp.Simple: + + def run: IO[Unit] = withTempFile: path => + for + _ <- path.writeLines(LazyList.from('a').map(_.toChar.toString).take(26)) + alphabet <- path.read + _ <- IO.println("ASCII took hispanics into account!").whenA(alphabet.contains('Γ±')) + yield () + +end Temporary +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import cats.syntax.all.* +import cats.effect.{IO, IOApp} + +object Temporary extends IOApp.Simple: + + def run: IO[Unit] = Catscript.withTempFile: path => + for + _ <- Catscript.writeLines(path, LazyList.from('a').map(_.toChar.toString).take(26)) + alphabet <- Catscript.read(path) + _ <- IO.println("ASCII took hispanics into account!").whenA(alphabet.contains('Γ±')) + yield () + +end Temporary +``` + +@:@ + +You will see that the `use` function goes from `Path => IO[A]`, and that `use` basically describes a path that will be used to compute an `A`, with some side effects along the way. When the computation is finished, the file will no longer exist. + +The last alternative is with `createTempFile` or `createTempDirectory`. The difference between `createTempFile` and `withTempFile` is that the `create` functions return the path of the file, for example: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Temporary extends IOApp.Simple: + + val secretPath = Path(".secrets/to_my_secret_lover.txt") + + def run: IO[Unit] = createTempFile.flatMap: path => + for + _ <- path.write("A confession to my lover: ") + letter <- secretPath.read + _ <- path.appendLine(letter) + yield () + +end Temporary +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Temporary extends IOApp.Simple: + + val secretPath = Path(".secrets/to_my_secret_lover.txt") + + def run: IO[Unit] = Catscript.createTempFile.flatMap: path => + for + _ <- Catscript.write(path,"A confession to my lover: ") + letter <- Catscript.read(secretPath) + _ <- Catscript.appendLine(path, letter) + yield () + +end Temporary +``` + +@:@ + +### Exercise + +Another really nice way to handle resource lifecycle [is with a `Resource`](https://typelevel.org/cats-effect/docs/std/resource#resource) from Cats Effect. Be adventurous and implement a third way of handling temporary files with a new function that returns a `Resource`. + + +```scala +import cats.effect.Resource + +def makeTempFile: Resource[IO, Path] = ??? + +def makeTempDirectory: Resource[IO, Path] = ??? +``` + +[See possible implementations](../examples/solutions.md#using-temporary-files) diff --git a/docs/tutorial/path.md b/docs/tutorial/path.md new file mode 100644 index 0000000..d274524 --- /dev/null +++ b/docs/tutorial/path.md @@ -0,0 +1,30 @@ +# Using Paths + +Catscript is implemented with [fs2-io](https://fs2.io/#/io), and one of the abstraction it provides is [`Path`](https://www.javadoc.io/static/co.fs2/fs2-docs_3/3.8.0/fs2/io/file/Path.html). It represents a path to a file or a directory. + +You can start using `Path`'s by importing the type from the library: + +```scala mdoc +import fs2.io.file.Path +``` + +## Creating a `Path` + +The recommended way for creating a `Path` is using the `apply` method of the companion object: + +```scala mdoc + +val path = Path("path/to/a/file/or/directory") +``` + +Once you did that, you can combine paths using some convenience methods: + +```scala mdoc +val parent = Path("parent/dir") + +val child = Path("child/dir") + +parent / child / "some/more/locations" +``` + +Note that these paths are compatible with the Java NIO paths, so you can use them interchangeably by calling the `toNioPath` value. \ No newline at end of file diff --git a/docs/tutorial/reading_writing.md b/docs/tutorial/reading_writing.md new file mode 100644 index 0000000..baf6095 --- /dev/null +++ b/docs/tutorial/reading_writing.md @@ -0,0 +1,340 @@ +# Reading and writing + +In this section, we will guide you through the process of opening and extracting information from files using our simple, intuitive API. Whether you are working with text, binary, or other formats, you will find the tools you need to seamlessly integrate file reading into your Scala project. Let us dive in and unlock the power of data stored in files! + + +## Reading and printing a file + +Let's say you want to load the contents of a file in memory... + +The first thing you need to do is import the `read` method and the `Path` type: + +@:select(api-style) + +@:choice(syntax) + +```scala +import catscript.syntax.path.* +``` + +@:choice(static) + +```scala +import catscript.Catscript +``` + +@:@ + +Next, define the `run` function to execute the `IO`. To do that, you need to extend your application with a `cats.effect.IOApp`, let us name it `App`: + +```scala mdoc:compile-only +import cats.effect.{IO, IOApp} + +object App extends IOApp.Simple: + + def run: IO[Unit] = ??? + +end App +``` + +Now we can start using the library! First, create a `Path` containing the path to the file you want to read: + +```scala mdoc:compile-only +import fs2.io.file.Path +val path = Path("testdata/readme.txt") +``` + +And use the `read` function to load the file in memory as a string: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:fail +import cats.effect.{IO, IOApp} + +object App extends IOApp.Simple: + + val path = Path("testdata/readme.txt") + + def run: IO[Unit] = path.read + +end App +``` + +@:choice(static) + +```scala mdoc:fail +import cats.effect.{IO, IOApp} + +object App extends IOApp.Simple: + + val path = Path("testdata/readme.txt") + + def run: IO[Unit] = Catscript.read(path) + +end App +``` + +@:@ + +Oops! We got an error saying that the `run` function accepts an `IO[Unit]`, but the `read` function returns an `IO[String]`. This happens because we are not doing anything with the string and therefore not returning `IO[Unit]`. Let's fix that by sequencing the value obtained from `read` with another that prints it to the console (and thus returns `IO[Unit]`). This can be achieved using the `flatMap` function as follows: + +@:select(api-style) + +@:choice(syntax) + +```scala +path.read.flatMap(file => IO(println(file))) +``` + +@:choice(static) + +```scala +Catscript.read(path).flatMap(file => IO(println(file))) +``` + +@:@ + +What is happening above is that we are calling the `flatMap` method and passing as parameter a function describing what we want to do with the `file` inside the `IO`, in this case, passing it to the computation `IO(println(file))`. + +Now pass the program to the `run` method and everything should go nicely: + + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object App extends IOApp.Simple: + + val path = Path("testdata/readme.txt") + + def run: IO[Unit] = path.read.flatMap(file => IO(println(file))) + +end App +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object App extends IOApp.Simple: + + val path = Path("testdata/readme.txt") + + def run: IO[Unit] = Catscript.read(path).flatMap(file => IO(println(file))) + +end App +``` + +@:@ + +Congratulations! You have just loaded the contents of a file in pure Scala πŸŽ‰. + +### Exercise + +You may not like to keep using the `flatMap` function over and over again to sequence computations. This is why there is the [`for` construct](https://docs.scala-lang.org/scala3/book/control-structures.html#for-expressions) to automatically let the compiler write the `flatMap`s for you. Why don't you try rewriting the program we just did using for-comprehensions? + +```scala +def run: IO[Unit] = + for + // Complete your code here! + ... + yield () +``` +[See solution](../examples/solutions.md#reading-and-printing-a-file) + +## Writing and modifying the contents of a file + +Now that you know how to load a file, you might also want to modify it and save it. + +To write to a file, use the `write` function to save the contents to a new file. Here, we reverse the file so it reads backwards: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object App extends IOApp.Simple: + + val path = Path("testdata/change_me.txt") + + def run: IO[Unit] = + for + file <- path.read + reversedFile = file.reverse + _ <- path.write(reversedFile) + yield () + +end App +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object App extends IOApp.Simple: + + val path = Path("testdata/change_me.txt") + + def run: IO[Unit] = + for + file <- Catscript.read(path) + reversedFile = file.reverse + _ <- Catscript.write(path, reversedFile) + yield () + +end App +``` + +@:@ + +Be aware that this will overwrite the contents of the file. So be careful not to change important files while you are learning! + +### Exercise + +Try loading the contents of two different files, concatenating them, and saving the result to a third location. How would you do it? + +[See possible solution](../examples/solutions.md#writing-and-modifying-the-contents-of-a-file) + +## Working line by line + +Catscript provides a method called `readLines`, which reads the file line by line and stores them on a `List[String]`. This comes handy when you are working with a list of things that you want to convert: + +`testdata/names.data` + +``` +Alex +Jamie +Morgan +Riley +Taylor +Casey +River +``` + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import org.typelevel.catscript.syntax.path.* +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Names extends IOApp.Simple: + + case class Name(value: String) + + val namesPath = Path("testdata/names.data") + + def run: IO[Unit] = + for + lines <- namesPath.readLines // (1) + names = lines.map(Name(_)) // (2) + _ <- IO(names.foreach(println)) // (3) + yield () + +end Names +``` + +@:choice(static) + +```scala mdoc:compile-only +import org.typelevel.catscript.Catscript +import fs2.io.file.Path +import cats.effect.{IO, IOApp} + +object Names extends IOApp.Simple: + + case class Name(value: String) + + val namesPath = Path("testdata/names.data") + + def run: IO[Unit] = + for + lines <- Catscript.readLines(namesPath) // (1) + names = lines.map(Name(_)) // (2) + _ <- IO(names.foreach(println)) // (3) + yield () + +end Names +``` + +@:@ + +Here is what's happening: + +**(1)** Load the list of names as `List` + +**(2)** Convert it to `Name` + +**(3)** Print the list to the console + + +### Exercise + +Write a function that reads a file into lines, adds a blank line between the lines and saves the result in a different file. For example given the following input: + +_`testdata/edgar_allan_poe/no_spaced_dream.txt`_ + +``` +Take this kiss upon the brow! +And, in parting from you now, +Thus much let me avow β€” +You are not wrong, who deem +That my days have been a dream; +Yet if hope has flown away +In a night, or in a day, +In a vision, or in none, +Is it therefore the less gone? +All that we see or seem +Is but a dream within a dream. +``` + +Your program should output the following: + +_`testdata/edgar_allan_poe/spaced_dream.txt`_ + +``` +Take this kiss upon the brow! + +And, in parting from you now, + +Thus much let me avow β€” + +You are not wrong, who deem + +That my days have been a dream; + +Yet if hope has flown away + +In a night, or in a day, + +In a vision, or in none, + +Is it therefore the less gone? + +All that we see or seem + +Is but a dream within a dream. +``` + +How would you do this? (hint: use `writeLines`). + +[See possible solution](../examples/solutions.md#working-line-by-line) diff --git a/docs/wiki/directory.conf b/docs/wiki/directory.conf new file mode 100644 index 0000000..6630523 --- /dev/null +++ b/docs/wiki/directory.conf @@ -0,0 +1,5 @@ +laika.title = Files +laika.navigationOrder = [ + file_reading_writing.md + file_manipulation.md +] \ No newline at end of file diff --git a/docs/wiki/file_manipulation.md b/docs/wiki/file_manipulation.md new file mode 100644 index 0000000..798b979 --- /dev/null +++ b/docs/wiki/file_manipulation.md @@ -0,0 +1,528 @@ +# Handling files and doing operations + +Beyond simple file reading and writing operations, Catscript allows you to interact directly with the file system. There are functions for creating and deleting files and directories, creating temporary files for short-term use and managing file and directory permissions for added control, among other useful methods. + +## Creating files and directories + +In this section, you will see how to create new files and directories, as well as delete existing ones. + +### `createFile` + +Creates a new file in the specified path, failing if the parent directory does not exist. It optionally accepts file permissions. To see what the `exists` function does, see [the reference](#exists): + +```scala mdoc:invisible +// This section adds every import to the code snippets + +import cats.effect.IO +import cats.syntax.all.* + +import fs2.Stream +import fs2.io.file.{Path, Files} + +import org.typelevel.catscript +import catscript.syntax.path.* +import catscript.Catscript + +val path = Path("testdata/dummy.something") +``` + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import catscript.syntax.path.* + +val path = Path("path/to/create/file.txt") + +path.createFile >> path.exists // Should return true +``` + +@:choice(static) + +```scala mdoc:compile-only +import catscript.Catscript + +val path = Path("path/to/create/file.txt") + +Catscript.createFile(path) >> Catscript.exists(path) // Should return true +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.io.file.Files + +val path = Path("path/to/create/file.txt") + +Files[IO].createFile(path) >> Files[IO].exists(path) // Should return true +``` + +@:@ + +### `createDirectories` + +Creates all the directories in the path, with the default permissions or with the supplied ones: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +val directories = Path("here/are/some/dirs") +val path = directories / Path("file.txt") + +directories.createDirectories >> path.createFile +``` + +@:choice(static) + +```scala mdoc:compile-only +val directories = Path("here/are/some/dirs") +val path = directories / Path("file.txt") + +Catscript.createDirectories(directories) >> Catscript.createFile(path) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +val directories = Path("here/are/some/dirs") +val path = directories / Path("file.txt") + +Files[IO].createDirectories(directories) >> Files[IO].createFile(path) +``` + +@:@ + +### `createTempFile` + +This function creates a temporary file that gets automatically deleted. It optionally accepts multiple parameters such as a directory, a prefix (to the name of the file), a suffix (like the extension of the file) and permissions. It returns the path of the newly created file: + + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +for + path <- createTempFile + + // It's going to be deleted eventually! + _ <- path.write("I don't wanna go!") +yield () +``` + +@:choice(static) + +```scala mdoc:compile-only +for + path <- Catscript.createTempFile + + // It's going to be deleted eventually! + _ <- Catscript.write(path, "I don't wanna go!") +yield () +``` + +@:choice(fs2) + +```scala mdoc:compile-only +Stream.eval(Files[IO].createTempFile) + .flatMap( path => + Stream.emit("I don't wanna go!") + .through(Files[IO].writeUtf8(path)) + ) + .compile + .drain +``` + +@:@ + +### `withTempFile` + +Very similar to `createTempFile`, but Cats Effect handles the deletion of the file by itself. Accepts the same parameters as a custom directory, a prefix, a suffix, and some permissions but takes a `use` function as well. This function is a description of how the path will be used and what will be computed after that: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc:compile-only +withTempFile: path => + path.write("I have accepted my fate...") +``` + +@:choice(static) + +```scala 3 mdoc:compile-only +Catscript.withTempFile: path => + Catscript.write(path, "I have accepted my fate...") +``` + +@:choice(fs2) + +```scala mdoc:compile-only +Files[IO].tempFile.use: path => + Stream.emit("I have accepted my fate...") + .through(Files[IO].writeUtf8(path)) + .compile + .drain +``` + +@:@ + +### `createTempDirectory` + +Creates a temporary directory that will eventually be deleted by the operating system. It accepts a few optional parameters like a custom parent directory, a prefix, and some permissions. It returns the path to the newly created directory: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +for + dir <- createTempDirectory + _ <- (dir / "tempfile.tmp").createFile +yield () +``` + +@:choice(static) + +```scala mdoc:compile-only +for + dir <- Catscript.createTempDirectory + _ <- Catscript.createFile(dir / "tempfile.tmp") +yield () + +``` + +@:choice(fs2) + +```scala mdoc:compile-only +for + dir <- Files[IO].createTempDirectory + _ <- Files[IO].createFile(dir / "tempfile.tmp") +yield () + +``` + +@:@ + +### `withTempDirectory` + +Similar to `createTempDirectory`, but the deletion of the directory is managed by Cats Effect. Takes the same arguments as a custom directory, a prefix and some permissions and most importantly, a `use` function that describes how the directory will be used and computed: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc:compile-only +withTempDirectory: dir => + (dir / "its_going_to_go_soon.mp3").createFile +``` + +@:choice(static) + +```scala 3 mdoc:compile-only +Catscript.withTempDirectory: dir => + Catscript.createFile(dir / "its_going_to_go_soon.mp3") +``` + +@:choice(fs2) + +```scala mdoc:compile-only +Files[IO].tempDirectory.use: dir => + Files[IO].createFile(dir / "its_going_to_go_soon.mp3") + +``` + +@:@ + +### `createSymbolicLink` + +Creates a [Symbolic Link](https://en.wikipedia.org/wiki/Symbolic_link) to a file. Requires the destination of the symlink and the path of the target file to link, and optionally some permissions: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +val linkPath = Path("store/the/link/here/symlink") +val targetPath = Path("path/to/file/to/target.sh") + +linkPath.createSymbolicLink(targetPath) +``` + +@:choice(static) + +```scala mdoc:compile-only +val linkPath = Path("store/the/link/here/symlink") +val targetPath = Path("path/to/file/to/target.sh") + +Catscript.createSymbolicLink(linkPath, targetPath) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +val linkPath = Path("store/the/link/here/symlink") +val targetPath = Path("path/to/file/to/target.sh") + +Files[IO].createSymbolicLink(linkPath, targetPath) +``` + +@:@ + +## Deleting files and directories + +### `delete` + +Deletes a file or empty directory that must exist (otherwise it will fail). + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +path.createFile >> + path.write("TOP SECRET 🚫, MUST DELETE") >> + path.delete +``` + +@:choice(static) + +```scala mdoc:compile-only +Catscript.createFile(path) >> + Catscript.write(path, "TOP SECRET 🚫, MUST DELETE") >> + Catscript.delete(path) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +for + _ <- Files[IO].createFile(path) + + _ <- Stream.emit("TOP SECRET 🚫, MUST DELETE") + .through(Files[IO].writeUtf8(path)) + .compile + .drain + + _ <- Files[IO].delete(path) +yield () +``` + +@:@ + +### `deleteIfExists` + +Similar to `delete`, but returns `true` if the deletion was successful instead of failing: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +path.deleteIfExists >>= + (deleted => IO.println(s"Was the file deleted? $deleted")) +``` + +@:choice(static) + +```scala mdoc:compile-only +Catscript.deleteIfExists(path) >>= + (deleted => IO.println(s"Was the file deleted? $deleted")) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +Files[IO].deleteIfExists(path) >>= + (deleted => IO.println(s"Was the file deleted? $deleted")) +``` + +@:@ + +### `deleteRecursively` + +With the previous functions, the directory had to be empty to be deleted. The difference with this method is that it recursively deletes all files or folders contained inside it, optionally following symbolic links if specified. + +Note that, unlike the previous functions, this one will not fail if the directories are empty or do not exist: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 +val dirs = Path("this/folders/will/be/created/and/deleted") + +for + _ <- dirs.createDirectories + _ <- dirs.deleteRecursively // Will delete all of them! +yield () +``` + +@:choice(static) + +```scala 3 +val dirs = Path("this/folders/will/be/created/and/deleted") + +for + _ <- Catscript.createDirectories(dirs) + _ <- Catscript.deleteRecursively(dirs) // Will delete all of them! +yield () +``` + +@:choice(fs2) + +```scala 3 + +val dirs = Path("this/folders/will/be/created/and/deleted") + +for + _ <- Files[IO].createDirectories(dirs) + _ <- Files[IO].deleteRecursively(dirs) // Will delete all of them! +yield () +``` + +@:@ + + +## File operations + +catscript provides essential functions for renaming, moving, and copying files, allowing you to efficiently manage your data. These are especially useful in scripting scenarios. + +### `copy` + +Copies a file from a source path to a target path. The method will fail if the destination path already exists; to avoid this behaviour, you can, for example, pass flags to replace the contents at destination: + + +@:select(api-style) + +@:choice(syntax) + +```scala 3 +val source = Path("source/file/secret.txt") +val target = Path("target/dir/not_so_secret.txt") + +for + _ <- source.write("The no-cloning theorem says you can't copy me!") + _ <- source.copy(target) +yield () +``` + +@:choice(static) + +```scala 3 +val source = Path("source/file/secret.txt") +val target = Path("target/dir/not_so_secret.txt") + +for + _ <- Catscript.write(source, "The no-cloning theorem says you can't copy me!") + _ <- Catscript.copy(source, target) +yield () +``` + +@:choice(fs2) + +```scala 3 +val source = Path("source/file/secret.txt") +val target = Path("target/dir/not_so_secret.txt") + +Stream.emit("The no-cloning theorem says you can't copy me!") + .through(Files[IO].writeUtf8(source)) + .evalTap(_ => Files[IO].copy(source, target)) + .compile + .drain +``` + +@:@ + +### `move` + +Very similar to `copy`, but deletes the file in the original destination path. Optionally takes flags as arguments to define its move behaviour: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 +val source = Path("i/cant/move.mp4") +val target = Path("teleporting/around/movie.mp4") + +source.move(target) +``` + +@:choice(static) + +```scala 3 +val source = Path("i/cant/move.mp4") +val target = Path("teleporting/around/movie.mp4") + +Catscript.move(source, target) +``` + +@:choice(fs2) + +```scala 3 +val source = Path("i/cant/move.mp4") +val target = Path("teleporting/around/movie.mp4") + +Files[IO].move(source, target) +``` + +@:@ + +### `exists` + +This function checks whether a file exists at a specified path: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 +import cats.syntax.all.* // for the whenA method + +val source = Path("need/to/ve/copied/bin.sha256") +val target = Path("need/to/be/deleted/bin.sha254") + +for + _ <- target.delete // Delete before copying to avoid errors (and flags) + exists <- target.exists + _ <- source.copy(target).whenA(exists) +yield () +``` + +@:choice(static) + +```scala 3 +import cats.syntax.all.* // for the whenA method + +val source = Path("need/to/ve/copied/bin.sha256") +val target = Path("need/to/be/deleted/bin.sha254") + +for + _ <- Catscript.delete(target) // Delete before copying to avoid errors (and flags) + exists <- Catscript.exists(target) + _ <- Catscript.copy(source, target).whenA(exists) +yield () +``` + +@:choice(fs2) + +```scala 3 +import cats.syntax.all.* // for the whenA method + +val source = Path("need/to/ve/copied/bin.sha256") +val target = Path("need/to/be/deleted/bin.sha254") + +for + _ <- Files[IO].delete(target) // Delete before copying to avoid errors (and flags) + exists <- Files[IO].exists(target) + _ <- Files[IO].copy(source, target).whenA(exists) +yield () +``` + +@:@ + + diff --git a/docs/wiki/file_reading_writing.md b/docs/wiki/file_reading_writing.md new file mode 100644 index 0000000..979e2a4 --- /dev/null +++ b/docs/wiki/file_reading_writing.md @@ -0,0 +1,737 @@ +# Reading, writing, and appending + +Catscript comes with three different functions for reading and writing operations, `read`, `write` and `append`, each with four different variants: Standalone, `Bytes`, `Lines` and `As`, with these variants you will be able to work with the file and/or its contents as a UTF-8 string or with a custom `java.nio.charset.Charset`, as bytes, line by line, and with a custom codec respectively. + +```scala mdoc:invisible +// This section adds every import to the code snippets + +import cats.effect.IO +import cats.syntax.all.* + +import fs2.Stream +import fs2.io.file.{Path, Files} + +import org.typelevel.catscript +import catscript.syntax.path.* +import catscript.Catscript + +val path = Path("testdata/dummy.something") +``` + +## Reading + +One of the most common operations in scripting is reading a file, that is why you can read a file in different ways: + +### `read` + +This function loads the whole file as a string in memory using UTF-8 encoding. + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import cats.syntax.all.* + +import catscript.syntax.path.* + +val path = Path("link/to/your/file.txt") + +path.read >>= IO.println +``` + +@:choice(static) + +```scala scala mdoc:compile-only +import cats.syntax.all.* + +import catscript.Catscript + +val path = Path("link/to/your/file.txt") + +Catscript.read(path) >>= IO.println +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.io.file.Files + +val path = Path("link/to/your/file.txt") + +Files[IO].readUtf8(path).evalMap(IO.println).compile.drain +``` + +@:@ + +If you need to handle non UTF-8 files, you can also use a custom `java.nio.charset.Charset`: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc:compile-only +import java.nio.charset.StandardCharsets + +path.read(StandardCharsets.UTF_16) +``` + +@:choice(static) + +```scala 3 mdoc:compile-only +import java.nio.charset.StandardCharsets + +Catscript.read(path, StandardCharsets.UTF_16) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.text.decodeWithCharset +import java.nio.charset.StandardCharsets + +Files[IO].readAll(path) + .through(decodeWithCharset(StandardCharsets.UTF_16)) + .compile + .string +``` + +@:@ + +### `readBytes` + +Reads the file as a [`ByteVector`](https://scodec.org/guide/scodec-bits.html#ByteVector), useful when working with binary data: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +path.readBytes.map(_.rotateLeft(2)) +``` + +@:choice(static) + +```scala mdoc:compile-only +Catscript.readBytes(path).map(_.rotateLeft(2)) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import scodec.bits.ByteVector + +Files[IO].readAll(path).compile.to(ByteVector).map(_.rotateLeft(2)) +``` + +@:@ + +If you are not used to working with bytes and bits in general, `rotateLeft(N)` will shift bits N number of places to the left! For example, if we rotate to the left 11100101 three times, we will get 00101111. + + + +### `readLines` + +Similar to `read` as it reads the file as a UTF-8 string, but stores each line of the file in a `List`: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +for + lines <- path.readLines + _ <- lines.traverse_(IO.println) +yield () +``` + +@:choice(static) + +```scala mdoc:compile-only +for + lines <- Catscript.readLines(path) + _ <- IO(lines.foreach(println)) +yield () +``` + +@:choice(fs2) + +```scala mdoc:compile-only +Files[IO].readUtf8Lines(path).evalMap(IO.println).compile.drain +``` + +@:@ + + +### `readAs` + +This method reads the contents of the file given a `Codec[A]`. Useful when you want to convert a binary file into a custom type `A`: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import scodec.Codec + +case class Coordinates(x: Double, y: Double) derives Codec + +path.readAs[Coordinates].map(coord => coord.x + coord.y) +``` + +@:choice(static) + +```scala mdoc:compile-only +import scodec.Codec + +case class Coordinates(x: Double, y: Double) derives Codec + +Catscript.readAs[Coordinates](path).map(coord => coord.x + coord.y) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import scodec.Codec +import fs2.interop.scodec.StreamDecoder + +case class Coordinates(x: Double, y: Double) derives Codec + +Files[IO].readAll(path) + .through(StreamDecoder.many(summon[Codec[Coordinates]]).toPipeByte) + .map(coord => coord.x + coord.y) + .compile + .drain +``` + +@:@ + + +## Writing + +You can also overwrite the contents of a file using the `write` method and any of its variants. If the the file does not exist, it will be created automatically: + +### `write` + +Writes to the desired file in the path. The contents of the file will be written as a UTF-8 string: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +val path = Path("path/to/write.txt") + +val poem = + """Music, thou queen of heaven, care-charming spell, + |That strik'st a stillness into hell; + |Thou that tam'st tigers, and fierce storms, that rise, + |With thy soul-melting lullabies; + |Fall down, down, down, from those thy chiming spheres + |To charm our souls, as thou enchant'st our ears. + | + |β€” Robert Herrick""".stripMargin + +path.write(poem) +``` + +@:choice(static) + +```scala mdoc:compile-only +val path = Path("path/to/write.txt") + +val poem = + """Music, thou queen of heaven, care-charming spell, + |That strik'st a stillness into hell; + |Thou that tam'st tigers, and fierce storms, that rise, + |With thy soul-melting lullabies; + |Fall down, down, down, from those thy chiming spheres + |To charm our souls, as thou enchant'st our ears. + | + |β€” Robert Herrick""".stripMargin + +Catscript.write(path, poem) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +val path = Path("path/to/write.txt") + +val poem = + """Music, thou queen of heaven, care-charming spell, + |That strik'st a stillness into hell; + |Thou that tam'st tigers, and fierce storms, that rise, + |With thy soul-melting lullabies; + |Fall down, down, down, from those thy chiming spheres + |To charm our souls, as thou enchant'st our ears. + | + |β€” Robert Herrick""".stripMargin + +Stream.emit(poem) + .through(Files[IO].writeUtf8(path)) + .compile + .drain +``` + +@:@ + +The default charset can also be changed for encoding when writing strings: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc:compile-only +import java.nio.charset.StandardCharsets + +val message = "Java? No thanks, I'm allergic to coffee Ξ»" + +path.write(message, StandardCharsets.US_ASCII) +``` + +@:choice(static) + +```scala 3 mdoc:compile-only +import java.nio.charset.StandardCharsets + +val message = "Java? No thanks, I'm allergic to coffee Ξ»" + +Catscript.write(path, message, StandardCharsets.US_ASCII) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.text.encode +import java.nio.charset.StandardCharsets + +val message = "Java? No thanks, I'm allergic to coffee Ξ»" + +Stream.emit(message) + .through(encode(StandardCharsets.US_ASCII)) + .through(Files[IO].writeAll(path)) + .compile + .drain +``` + +@:@ + +### `writeBytes` + +With this method you can write bytes directly to a binary file. The contents of the file require to be in form of a `scodec.bits.ByteVector`: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import scodec.bits.* +val niceBytes = hex"DeadC0de" + +path.writeBytes(niceBytes) +``` + +@:choice(static) + +```scala mdoc:compile-only +import scodec.bits.* +val niceBytes = hex"DeadC0de" + +Catscript.writeBytes(path, niceBytes) +``` + +@:choice(fs2) + +```scala mdoc:compile-only + + +import scodec.bits.* +val niceBytes = hex"DeadC0de" + +Stream.chunk(fs2.Chunk.byteVector(niceBytes)) + .covary[IO] + .through(Files[IO].writeAll(path)) + .compile + .drain +``` + +@:@ + +### `writeLines` + +If you want to write many lines to a file, you can provide the contents as a collection of strings, and this function will write each element as a new line: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +val todoList = List( + "Buy Milk", + "Finish the pending work", + "Close the bank account", + "Mow the yard", + "Take the dog for a walk" +) + +path.writeLines(todoList) +``` + +@:choice(static) + +```scala mdoc:compile-only +val todoList = List( + "Buy Milk", + "Finish the pending work", + "Close the bank account", + "Mow the yard", + "Take the dog for a walk" +) + +Catscript.writeLines(path, todoList) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +val todoList = List( + "Buy Milk", + "Finish the pending work", + "Close the bank account", + "Mow the lawn", + "Take the dog for a walk" +) + +Stream.emits(todoList) + .through(Files[IO].writeUtf8Lines(path)) + .compile + .drain +``` + +@:@ + +### `writeAs` + +This method allows you to write a custom type `A` to a file, given a `Codec[A]` in scope: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import scodec.Codec + +case class Rectangle(base: Float, height: Float) derives Codec + +path.writeAs[Rectangle]( Rectangle(2.4, 6.0) ) +``` + +@:choice(static) + +```scala mdoc:compile-only +import scodec.Codec + +case class Rectangle(base: Float, height: Float) derives Codec + +Catscript.writeAs[Rectangle](path, Rectangle(2.4, 6.0)) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import scodec.Codec +import fs2.interop.scodec.StreamEncoder + +case class Rectangle(base: Float, height: Float) derives Codec + +Stream.emit( Rectangle(2.4, 6.0) ) + .covary[IO] + .through(StreamEncoder.many(summon[Codec[Rectangle]]).toPipeByte) + .through(Files[IO].writeAll(path)) + .compile + .drain +``` + +@:@ + +## Appending + +Appending is very similar to writing, except that, instead of overwriting the contents of the file, it writes them to the very end of the existing file without deleting the previous contents. + +### `append` + +Similar to `write`, but adds the content to the end of the file instead of overwriting it. The function will append the contents to the last line of the file if you want to append the contents as a new line, see [`appendLine`](#appendline). + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +val path = Path("path/to/append.txt") + +val secretFormula = "And the dish's final secret ingredient is " + +for + _ <- path.write(secretFormula) + _ <- path.append("Rustacean legs! πŸ¦€") +yield () +``` + +@:choice(static) + +```scala mdoc:compile-only +val path = Path("path/to/append.txt") + +val secretFormula = "And the dish's final secret ingredient is " + +for + _ <- Catscript.write(path, secretFormula) + _ <- Catscript.append(path, "Rustacean legs! πŸ¦€") +yield () + +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.io.file.Flags + +val path = Path("path/to/append.txt") + +val secretFormula = "And the dish's final secret ingredient is " + +Stream.emit(secretFormula) + .covary[IO] + .through(Files[IO].writeUtf8(path)) + .map( _ => "Rustacean legs! πŸ¦€") + .through(Files[IO].writeUtf8(path, Flags.Append)) + .compile + .drain +``` + +@:@ + +Like in the other variants, you can also use a custom charset to encode strings: + +@:select(api-style) + +@:choice(syntax) + +```scala 3 mdoc:compile-only +import java.nio.charset.StandardCharsets + +path.append("Which encoding I'm I?", StandardCharsets.ISO_8859_1) +``` + +@:choice(static) + +```scala 3 mdoc:compile-only +import java.nio.charset.StandardCharsets + +Catscript.append(path, "Which encoding I'm I?", StandardCharsets.ISO_8859_1) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.text.encode + +import java.nio.charset.StandardCharsets + +Stream.emit("Which encoding I'm I?") + .through(encode(StandardCharsets.ISO_8859_1)) + .through(Files[IO].writeAll(path)) + .compile + .drain +``` + +@:@ + + +### `appendLine` + +Very similar to `append`, with the difference that appends the contents as a new line (equivalent to prepending a `\n` to the contents): + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +for + _ <- path.write("I'm at the top!") + _ <- path.appendLine("I'm at the bottom 😞") +yield () +``` + +@:choice(static) + +```scala mdoc:compile-only +for + _ <- Catscript.write(path, "I'm at the top!") + _ <- Catscript.appendLine(path, "I'm at the bottom 😞") +yield () +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.io.file.Flags + +Stream.emit("I'm at the top!") + .through(Files[IO].writeUtf8(path)) + .map(_ => "\nI'm at the bottom 😞") + .through(Files[IO].writeUtf8(path, Flags.Append)) + .compile + .drain +``` + +@:@ + + +### `appendBytes` + +This function can be used to append bytes to the end of a binary file: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import scodec.bits.ByteVector + +for + document <- path.read + signature <- IO(document.hashCode().toByte) + _ <- path.appendBytes(ByteVector(signature)) +yield () +``` + +@:choice(static) + +```scala mdoc:compile-only +import scodec.bits.ByteVector + +for + document <- Catscript.read(path) + signature <- IO(document.hashCode().toByte) + _ <- Catscript.appendBytes(path, ByteVector(signature)) +yield () +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.io.file.Flags + +Files[IO].readUtf8(path) + .evalMap(document => IO(document.hashCode().toByte)) + .through(Files[IO].writeAll(path, Flags.Append)) + .compile + .drain +``` + +@:@ + + +### `appendLines` + +You can also append multiple lines at the end of the file in the following way: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +val missingIngredients = Vector( + "40 ladyfingers.", + "6 egg yolks", + "3/4 cup granulated sugar.", + "500 ml mascarpone, cold." +) + +path.appendLines(missingIngredients) +``` + +@:choice(static) + +```scala mdoc:compile-only +val missingIngredients = Vector( + "40 ladyfingers.", + "6 egg yolks", + "3/4 cup granulated sugar.", + "500 ml mascarpone, cold." +) + +Catscript.appendLines(path, missingIngredients) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import fs2.io.file.Flags + +val missingIngredients = Vector( + "40 ladyfingers.", + "6 egg yolks", + "3/4 cup granulated sugar.", + "500 ml mascarpone, cold." +) + +Stream.emits(missingIngredients) + .through(Files[IO].writeUtf8Lines(path, Flags.Append)) + .compile + .drain +``` + +@:@ + +### `appendAs` +Finally, given a `Codec[A]` in the scope, this method will append a custom type `A` to the end of a file: + +@:select(api-style) + +@:choice(syntax) + +```scala mdoc:compile-only +import scodec.Codec +import scodec.codecs.* + +opaque type Ranking = (Int, Long) +given rankingCodec: Codec[Ranking] = uint8 :: int64 + +path.appendAs[Ranking]( (2, 3120948123123L) ) +``` + +@:choice(static) + +```scala mdoc:compile-only +import scodec.Codec +import scodec.codecs.* + +opaque type Ranking = (Int, Long) +given rankingCodec: Codec[Ranking] = uint8 :: int64 + +Catscript.appendAs[Ranking](path, (2, 3120948123123L)) +``` + +@:choice(fs2) + +```scala mdoc:compile-only +import scodec.Codec +import scodec.codecs.* +import fs2.interop.scodec.* +import fs2.io.file.Flags + +opaque type Ranking = (Int, Long) +given rankingCodec: Codec[Ranking] = uint8 :: int64 + +Stream.emit( (2, 3120948123123L) ) + .covary[IO] + .through(StreamEncoder.many(summon[Codec[Ranking]]).toPipeByte) + .through(Files[IO].writeAll(path, Flags.Append)) + .compile + .drain +``` + +@:@ \ No newline at end of file diff --git a/examples/src/main/scala/org/typelevel/catscript/contacts/app/App.scala b/examples/src/main/scala/org/typelevel/catscript/contacts/app/App.scala new file mode 100644 index 0000000..9032dab --- /dev/null +++ b/examples/src/main/scala/org/typelevel/catscript/contacts/app/App.scala @@ -0,0 +1,56 @@ +/* + * Copyright 2024 Typelevel + * + * 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 org.typelevel.catscript.contacts.app + +import cats.syntax.applicative.* +import cats.effect.{ExitCode, IO, IOApp} +import fs2.io.file.Path + +import org.typelevel.catscript.contacts.cli.{Cli, Prompt} +import org.typelevel.catscript.contacts.core.ContactManager +import org.typelevel.catscript.contacts.domain.argument.* +import org.typelevel.catscript.syntax.path.* + +object App extends IOApp { + + private val getOrCreateBookPath: IO[Path] = for { + home <- userHome + dir = home / ".catscript" + path = dir / "contacts.data" + exists <- path.exists + _ <- dir.createDirectories.unlessA(exists) + _ <- path.createFile.unlessA(exists) + } yield path + + def run(args: List[String]): IO[ExitCode] = getOrCreateBookPath + .map(ContactManager(_)) + .flatMap { implicit cm => + Prompt.parsePrompt(args) match { + case Help => Cli.helpCommand + case AddContact => Cli.addCommand + case RemoveContact(username) => Cli.removeCommand(username) + case SearchId(username) => Cli.searchUsernameCommand(username) + case SearchName(name) => Cli.searchNameCommand(name) + case SearchEmail(email) => Cli.searchEmailCommand(email) + case SearchNumber(number) => Cli.searchNumberCommand(number) + case ViewAll => Cli.viewAllCommand + case UpdateContact(username, flags) => + Cli.updateCommand(username, flags) + } + } + .as(ExitCode.Success) +} diff --git a/examples/src/main/scala/org/typelevel/catscript/contacts/cli/Cli.scala b/examples/src/main/scala/org/typelevel/catscript/contacts/cli/Cli.scala new file mode 100644 index 0000000..6277246 --- /dev/null +++ b/examples/src/main/scala/org/typelevel/catscript/contacts/cli/Cli.scala @@ -0,0 +1,130 @@ +/* + * Copyright 2024 Typelevel + * + * 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 org.typelevel.catscript.contacts.cli + +import cats.effect.IO +import cats.syntax.all.* + +import org.typelevel.catscript.contacts.core.ContactManager +import org.typelevel.catscript.contacts.domain.flag.* +import org.typelevel.catscript.contacts.domain.contact.* + +object Cli { + + def addCommand(implicit cm: ContactManager): IO[Unit] = + for { + username <- IO.println("Enter the username: ") >> IO.readLine + firstName <- IO.println("Enter the first name: ") >> IO.readLine + lastName <- IO.println("Enter the last name: ") >> IO.readLine + phoneNumber <- IO.println("Enter the phone number: ") >> IO.readLine + email <- IO.println("Enter the email: ") >> IO.readLine + + contact = Contact(username, firstName, lastName, phoneNumber, email) + + _ <- cm + .addContact(contact) + .flatMap(username => IO.println(s"Contact $username added")) + .handleErrorWith { + case ContactFound(username) => + IO.println(s"Contact $username already exists") + case e => + IO.println(s"An error occurred: \n${e.printStackTrace()}") + } + } yield () + + def removeCommand(username: Username)(implicit cm: ContactManager): IO[Unit] = + cm.removeContact(username) >> IO.println(s"Contact $username removed") + + def searchUsernameCommand( + username: Username + )(implicit cm: ContactManager): IO[Unit] = + cm.searchUsername(username).flatMap { + case Some(c) => IO.println(c.show) + case None => IO.println(s"Contact $username not found") + } + + def searchNameCommand(name: Name)(implicit cm: ContactManager): IO[Unit] = + for { + contacts <- cm.searchName(name) + _ <- contacts.traverse_(c => IO.println(c.show)) + } yield () + + def searchEmailCommand(email: Email)(implicit cm: ContactManager): IO[Unit] = + for { + contacts <- cm.searchEmail(email) + _ <- contacts.traverse_(c => IO.println(c.show)) + } yield () + + def searchNumberCommand( + number: PhoneNumber + )(implicit cm: ContactManager): IO[Unit] = + for { + contacts <- cm.searchNumber(number) + _ <- contacts.traverse_(c => IO.println(c.show)) + } yield () + + def viewAllCommand(implicit cm: ContactManager): IO[Unit] = for { + contacts <- cm.getAll + _ <- contacts.traverse_(c => IO.println(c.show)) + } yield () + + def updateCommand(username: Username, options: List[Flag])(implicit + cm: ContactManager + ): IO[Unit] = cm + .updateContact(username) { prev => + options.foldLeft(prev) { (acc, flag) => + flag match { + case FirstNameFlag(name) => acc.copy(firstName = name) + case LastNameFlag(name) => acc.copy(lastName = name) + case PhoneNumberFlag(number) => acc.copy(phoneNumber = number) + case EmailFlag(email) => acc.copy(email = email) + case UnknownFlag(_) => acc + } + } + } + .flatMap(c => IO.println(s"Updated contact ${c.username}")) + .handleErrorWith { + case ContactNotFound(username) => + IO.println(s"Contact $username not found") + case e => + IO.println(s"An error occurred: \n${e.printStackTrace()}") + } + + def helpCommand: IO[Unit] = IO.println( + s""" + |Usage: contacts [command] + | + |Commands: + | add + | remove + | search id + | search name + | search email + | search number + | list + | update [flags] + | help + | + |Flags (for update command): + | --first-name + | --last-name + | --phone-number + | --email + | + |""".stripMargin + ) +} diff --git a/examples/src/main/scala/org/typelevel/catscript/contacts/cli/Prompt.scala b/examples/src/main/scala/org/typelevel/catscript/contacts/cli/Prompt.scala new file mode 100644 index 0000000..fd36f30 --- /dev/null +++ b/examples/src/main/scala/org/typelevel/catscript/contacts/cli/Prompt.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Typelevel + * + * 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 org.typelevel.catscript.contacts.cli + +import org.typelevel.catscript.contacts.domain.argument.* +import org.typelevel.catscript.contacts.domain.flag.* + +import scala.annotation.tailrec + +object Prompt { + def parsePrompt(args: List[String]): CliCommand = args match { + case "add" :: Nil => AddContact + case "remove" :: username :: Nil => RemoveContact(username) + case "search" :: "id" :: username :: Nil => SearchId(username) + case "search" :: "name" :: name :: Nil => SearchName(name) + case "search" :: "email" :: email :: Nil => SearchEmail(email) + case "search" :: "number" :: number :: Nil => SearchNumber(number) + case "list" :: _ => ViewAll + case "update" :: username :: options => + UpdateContact(username, parseUpdateFlags(options)) + case Nil => Help + case _ => Help + } + + private def parseUpdateFlags(options: List[String]): List[Flag] = { + + @tailrec + def tailParse(remaining: List[String], acc: List[Flag]): List[Flag] = + remaining match { + case Nil => acc + case "--first-name" :: firstName :: tail => + tailParse(tail, FirstNameFlag(firstName) :: acc) + case "--last-name" :: lastName :: tail => + tailParse(tail, LastNameFlag(lastName) :: acc) + case "--phone-number" :: phoneNumber :: tail => + tailParse(tail, PhoneNumberFlag(phoneNumber) :: acc) + case "--email" :: email :: tail => + tailParse(tail, EmailFlag(email) :: acc) + case flag :: _ => List(UnknownFlag(flag)) + } + + tailParse(options, Nil) + } +} diff --git a/examples/src/main/scala/org/typelevel/catscript/contacts/core/ContactManager.scala b/examples/src/main/scala/org/typelevel/catscript/contacts/core/ContactManager.scala new file mode 100644 index 0000000..1ebc556 --- /dev/null +++ b/examples/src/main/scala/org/typelevel/catscript/contacts/core/ContactManager.scala @@ -0,0 +1,101 @@ +/* + * Copyright 2024 Typelevel + * + * 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 org.typelevel.catscript.contacts.core + +import cats.syntax.all.* +import cats.effect.IO +import fs2.io.file.Path +import org.typelevel.catscript.syntax.path.* +import org.typelevel.catscript.contacts.domain.contact.* + +trait ContactManager { + def addContact(contact: Contact): IO[Username] + def removeContact(username: Username): IO[Unit] + def searchUsername(username: Username): IO[Option[Contact]] + def searchName(name: Name): IO[List[Contact]] + def searchEmail(email: Email): IO[List[Contact]] + def searchNumber(number: PhoneNumber): IO[List[Contact]] + def getAll: IO[List[Contact]] + def updateContact(username: String)(modify: Contact => Contact): IO[Contact] +} + +object ContactManager { + def apply(bookPath: Path): ContactManager = new ContactManager { + + private def parseContact(contact: String): IO[Contact] = + contact.split('|') match { + case Array(id, firstName, lastName, phoneNumber, email) => + Contact(id, firstName, lastName, phoneNumber, email).pure[IO] + case _ => + new Exception(s"Invalid contact format: $contact") + .raiseError[IO, Contact] + } + + private def encodeContact(contact: Contact): String = + s"${contact.username}|${contact.firstName}|${contact.lastName}|${contact.phoneNumber}|${contact.email}" + + private def saveContacts(contacts: List[Contact]): IO[Unit] = + bookPath.writeLines(contacts.map(encodeContact)) + + override def addContact(contact: Contact): IO[Username] = for { + contacts <- getAll + _ <- IO(contacts.contains(contact)).ifM( + ContactFound(contact.username).raiseError[IO, Unit], + saveContacts(contact :: contacts) + ) + } yield contact.username + + override def removeContact(username: Username): IO[Unit] = + for { + contacts <- getAll + filteredContacts = contacts.filterNot(_.username === username) + _ <- saveContacts(filteredContacts) + } yield () + + override def searchUsername(username: Username): IO[Option[Contact]] = + getAll.map(contacts => contacts.find(_.username === username)) + + override def searchName(name: Name): IO[List[Contact]] = + getAll.map(contacts => + contacts.filter(c => c.firstName === name || c.lastName === name) + ) + + override def searchEmail(email: Email): IO[List[Contact]] = + getAll.map(contacts => contacts.filter(_.email === email)) + + override def searchNumber(number: PhoneNumber): IO[List[Contact]] = + getAll.map(contacts => contacts.filter(_.phoneNumber === number)) + + override def getAll: IO[List[Contact]] = for { + lines <- bookPath.readLines + contacts <- lines.traverse(parseContact) + } yield contacts + + override def updateContact( + username: Username + )(modify: Contact => Contact): IO[Contact] = for { + contacts <- getAll + oldContact <- contacts.find(_.username === username) match { + case None => ContactNotFound(username).raiseError[IO, Contact] + case Some(contact) => contact.pure[IO] + } + updatedContact = modify(oldContact) + updatedContacts = updatedContact :: contacts.filterNot(_ == oldContact) + _ <- saveContacts(updatedContacts) + } yield updatedContact + } +} diff --git a/examples/src/main/scala/org/typelevel/catscript/contacts/domain/argument.scala b/examples/src/main/scala/org/typelevel/catscript/contacts/domain/argument.scala new file mode 100644 index 0000000..ec546e9 --- /dev/null +++ b/examples/src/main/scala/org/typelevel/catscript/contacts/domain/argument.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Typelevel + * + * 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 org.typelevel.catscript.contacts.domain + +import org.typelevel.catscript.contacts.domain.contact.* +import org.typelevel.catscript.contacts.domain.flag.Flag + +object argument { + sealed abstract class CliCommand + + case object AddContact extends CliCommand + + case class RemoveContact(username: Username) extends CliCommand + + case class SearchId(username: Username) extends CliCommand + + case class SearchName(name: Name) extends CliCommand + + case class SearchEmail(email: Email) extends CliCommand + + case class SearchNumber(number: PhoneNumber) extends CliCommand + + case class UpdateContact( + username: Username, + options: List[Flag] + ) extends CliCommand + + case object ViewAll extends CliCommand + + case object Help extends CliCommand +} diff --git a/examples/src/main/scala/org/typelevel/catscript/contacts/domain/contact.scala b/examples/src/main/scala/org/typelevel/catscript/contacts/domain/contact.scala new file mode 100644 index 0000000..f228cb5 --- /dev/null +++ b/examples/src/main/scala/org/typelevel/catscript/contacts/domain/contact.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Typelevel + * + * 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 org.typelevel.catscript.contacts.domain + +import scala.util.control.NoStackTrace + +object contact { + + type Username = String + type Name = String + type PhoneNumber = String + type Email = String + + case class Contact( + username: Username, + firstName: Name, + lastName: Name, + phoneNumber: PhoneNumber, + email: Email + ) { + def show: String = + s"""|------ $username ------ + | + |First Name: $firstName + |Last Name: $lastName + |Phone Number: $phoneNumber + |Email: $email + """.stripMargin + } + + case class ContactFound(username: Username) extends NoStackTrace + case class ContactNotFound(username: Username) extends NoStackTrace +} diff --git a/examples/src/main/scala/org/typelevel/catscript/contacts/domain/flag.scala b/examples/src/main/scala/org/typelevel/catscript/contacts/domain/flag.scala new file mode 100644 index 0000000..8ee90e4 --- /dev/null +++ b/examples/src/main/scala/org/typelevel/catscript/contacts/domain/flag.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Typelevel + * + * 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 org.typelevel.catscript.contacts.domain + +object flag { + sealed abstract class Flag + case class FirstNameFlag(firstName: String) extends Flag + case class LastNameFlag(lastName: String) extends Flag + case class PhoneNumberFlag(phoneNumber: String) extends Flag + case class EmailFlag(email: String) extends Flag + case class UnknownFlag(flag: String) extends Flag +}