The program described here is also available as TodoList.scala
in the examples
project.
Command Shell To-Do List
In this tutorial we will step things up a bit by introducing the Command Shell API, which provides a way to define behavior that works like a Unix shell, with commands, help, history, tab completion, and so on.
Preliminaries
First let’s deal with imports. Note that we’re importing the contents com.monovore.decline
from the decline library, which is included as a dependency of Tuco. We will use this functionality to define parsers for our commands. We also need tuco.shell._
here.
import com.monovore.decline.{ Command => Cmd, _ }
import cats._, cats.implicits._, cats.effect._
import tuco._, Tuco._
import tuco.shell._
Our application will manage a to-do list, providing the user with commands to add, delete, clear, and display the list. Our Todo
type just wraps a String
for now. As an exercise you may wish to add other fields (and commands to manipulate them).
case class Todo(text: String)
Let’s define some type aliases to ease things along.
type TodoState = List[Todo]
object TodoState {
val empty = Nil
}
type TodoAction = TodoState => SessionIO[TodoState]
Let’s decode these a bit.
- Our
TodoState
represents the behavior-specific state that this server will manage for each user session. Here it’s just a list of to-do items. - A
TodoAction
is a state transition that takes the current state and computes a new state, perhaps performingSessionIO
actions as well.
The Game Plan
We will define our telnet behavior in three steps.
- We will first define
TodoAction
s that encapsulate the “business logic” of our commands. - We will construct parsers that construct these actions. This is how we translates something the user types into a program we can run.
- We will construct an initial session state that includes our commands, a prompt string, our [initially empty] to-do list. From here the shell implementation is provided for us.
Defining our Actions
A TodoAction
is an effectful state transition that takes a TodoState
(a list of to-do items) and computes a new state, perhaps interacting with the user via SessionIO
effects.
Our first action will insert a new item at offset i
in the to-do list. By using patch
we make this operation safe for out-of-bounds indices. The implementation has no SessionIO
effect; it simply computes the new state.
def add(index: Int, text: String): TodoAction = ts =>
ts.patch(index, List(Todo(text)), 0).pure[SessionIO]
Our next action deletes the to-do item at the given index. Here we check the index and either point the computed state or complain to the user that the index is out of bound and return the state unchanged.
def delete(index: Int): TodoAction = { ts =>
if (ts.isDefinedAt(index)) ts.patch(index, Nil, 1).pure[SessionIO]
else writeLn(s"No such todo!").as(ts)
}
Our action that clears the list prompts the user to confirm, and returns either Nil
or the current state depending on the user’s answer.
val clear: TodoAction = ts =>
readLn("Are you sure (yes/no)? ").map(_.trim.toLowerCase).map {
case "yes" => Nil
case _ => ts
}
To display the to-do list to the user we number them, then truncate the resulting string at the terminal’s column limit in order to avoid wrapping. In the real world you might incorporate a word-wrapping algorithm here.
val list: TodoAction = ts =>
for {
cs <- getColumns
ss = ts.zipWithIndex.map { case (Todo(s), n) => f"${n + 1}%3d. $s".take(cs) }
_ <- ss.traverse(writeLn)
} yield ts
We have now defined our four actions. Next we will define commands that give our actions a textual representation for our users.
Defining Commands
Command[F[_], A]
is a data type with four fields:
- A name, which is simply a
String
like"add"
or"delete"
. - A description, which is a
String
that will be used to generate help documentation. - A parser that consumes
String
command arguments and returns an state transitionA => F[A]
for some arbitrary effect and state type (such as our actions above). - An optional tab completer for expanding command arguments when the user hits the Tab key. We will not use tab completion in this exercise.
Let’s walk through the first command slowly to be sure it all makes sense.
Implementing the Add Command
Recall the add
action we defined above.
def add(index: Int, text: String): TodoAction = ts =>
ts.patch(index, List(Todo(text)), 0).pure[SessionIO]
In order to call this action we need to parse two arguments from the commandline that the user types in: the index and the text. And in order to generate useful help documentation we need to provide some metadata about what the options mean. This is exactly what the scala-optparse-applicative library does, so Tuco relies on it directly.
Here is the parser for our index
argument.
val ind: Opts[Int] =
Opts.option[Int](
help = "List index where the todo should appear.",
short = "i",
long = "index",
metavar = "index"
).withDefault(1).map(_ - 1) // 1-based for the user, 0-based internally
We call intOption
which constructs a Parser[Int]
whose behavior is defined by the provided sequence of modifiers:
- A
help
string describing the option. - A
short
option flag. This means we can say-i 42
. - A
long
option flag. This means we can say--index 42
. - A
metavar
for the generated help string (see below). - A default value of
1
, if the user doesn’t specify.
The second argument to add
is the to-do item text, which is an arbitrary string. In our command this will be a required argument so we use strArgument
to construct its parser.
val txt: Opts[String] =
Opts.argument[String](metavar = "\"text\"")
We now have what we need to construct a Command
. We combine the parsers with mapN
to yield a Parser[TodoAction]
which is what we need for the third construtor argument.
val addCommand: Command[SessionIO, TodoState] =
Command("add", "Add a new todo.", (ind, txt).mapN(add))
Generalizing our State
We have defined a command that is specific to our domain model: the state type we are passing is TodoState
. But the command shell also has other state that it passes along, including the user prompt, command history, and the list of available commands. All of this state is available to our commands. But for now we only need our domain-specific hunk.
The state passed by the command shell is called Session[A]
where the domain-specific hunk is passed via the data
field of type A
. So what we really need in order to make our command compatible with the shell machinery is a Command[SessionIO, Session[TodoState]]
. We can do this by applying the zoom
operator that lenses down from the Session
to the TodoState
.
val addCommand: Command[SessionIO, Session[TodoState]] = {
Command("add", "Add a new todo.", (ind, txt).mapN(add))
.zoom(Session.data[TodoState]) // Session.data is a lens
}
This is our final addCommand
implementation.
Implementing the Remaining Commands
The delete
command takes a single argument so it’s reasonable to write the parser inline.
val deleteCommand = {
Command("delete", "Delete the specified item.",
Opts.argument[Int](
metavar = "index"
).map(n => delete(n - 1)))
.zoom(Session.data[List[Todo]])
}
The list and clear commands takes no arguments at all, so the parsers are simply lifted values.
val listCommand = {
Command("list", "List the todo items.", list.pure[Opts])
.zoom(Session.data[List[Todo]])
}
val clearCommand = {
Command("clear", "Clears the todo list.", clear.pure[Opts])
.zoom(Session.data[List[Todo]])
}
Putting it All Together
Now that we have defined our commands all that is remaining is to construct our initial session state to pass to runShell
. We construct an initial Session
with an empty to-do list, a custom command prompt, and a set of commands that includes Builtins
which gives us our help
, history
, and exit
commands.
val TodoCommands = Commands(addCommand, deleteCommand, listCommand, clearCommand)
val initialState: Session[TodoState] =
Session.initial(TodoState.empty).copy(
prompt = "todo> ",
commands = Builtins[TodoState] |+| TodoCommands
)
Our session behavior greets the user and runs the command shell with our initial state, yielding the final state. We report the length of our list and then exit.
val todo: SessionIO[Unit] =
for {
_ <- writeLn("Welcome to TODO!")
s <- runShell(initialState)
_ <- writeLn(s"Exiting with ${s.data.length} item(s) on your list.")
} yield ()
Our configuration is as before.
val conf = Config[IO](todo, 6666)
We can now run our to-do server and connect from another terminal window via telnet
.
scala> val stop = conf.start.unsafeRunSync
stop: cats.effect.IO[Unit] = IO$1504506943
Here is an example session.
$ telnet localhost 6666
Trying ::1...
Connected to localhost.
Escape character is '^]'.
Welcome to TODO!
todo> help
Available commands: <command> -h for more info.
add Add a new todo.
clear Clears the todo list.
delete Delete the specified item.
exit Exit the shell.
help Show command help.
history Show command history.
list List the todo items.
todo> add -h
Unexpected option: -h
Usage: add [--index <index>] <"text">
Add a new todo.
Options and flags:
--help
Display this help text.
--index <index>, -i <index>
List index where the todo should appear.
todo> add "Buy eggs."
todo> add "Wash the cat."
todo> list
1. Wash the cat.
2. Buy eggs.
todo> delete 42
No such todo!
todo> clear
Are you sure (yes/no)? yes
todo> list
todo> exit
Exiting with 0 item(s) on your list.
Connection closed by foreign host.
Shut the server down when you’re done.
scala> stop.unsafeRunSync