Part 1: Creating a REPL for Rescript using Tagless Final
Creating a REPL for Rescript using Tagless Final
The goal of this series of blog posts is to explore a small problem where Tagless Final can be applied. There's some initial code I wrote while hacking together a quick REPL for ReScript that I wanted to use, and in preparation for actually creating a proper library that could potentially be reused amongst the ReScript community, I figured a proper refactor is necessary.
This series of posts consists largely of code that I was copying here as I was implementing the REPL; and intended to be an overview, rather than being in depth.
If you're unfamiliar with Tagless Final, here's the blog post I read that introduced me to the approach; and a link to a ReScript Playground snippet where I translated a few of the examples from that blog post into ReScript. When I first saw the approach, dependency injection immediately came to mind.
Upon initial review of the spaghetti code that made up the prototype REPL, I identified and delineated two separate interfaces:
- Command Line
- Domain Logic
Where the Command Line interface for the Production instance of the interface leverages the Readline module from the NodeJs API, and a test instance may be provided in order to perform integration tests and verify the Production instance for the Domain Logic works appropriately in response to user input provided via the Command Line interface.
Of the two benefits of the Tagless Final approach, the second point made in the previous sentence is what I find to be an extremely compelling reason to use it.
src/interfaces/CommandLineIOAlg.res:
type commandLineIO<'a> = CommandLineIO('a)
module type CommandLineIOAlg = {
type t
let make : () => commandLineIO<t>
let prompt : commandLineIO<t> => string => (string => Promise.t<'a>) => Promise.t<'a>
let on : commandLineIO<t> => string => (() => ()) => commandLineIO<t>
let close : commandLineIO<t> => unit
}
src/interfaces/DomainLogicAlg.res:
type cont_or_close = Continue | Close
module type DomainLogicAlg = {
let handleUserInput : string => Promise.t<cont_or_close>
let cleanup : () => ()
}
src/repl.res:
open CommandLineIOAlg
open DomainLogicAlg
let handle_cont_or_close = (cont_or_close, cont, close): Promise.t<cont_or_close> => {
Promise.make((resolve, _reject) => {
switch cont_or_close {
| Continue => {
cont()->Promise.then(_ => Promise.make((res, _rej) => res(. ())))->ignore // the Promise.then becomes necessary because the recursive function is async... now
resolve(. cont_or_close)
}
| Close => {
Js.log("See you Space Cowboy")
close()
resolve(. cont_or_close)
}
}
})
}
let repl = async (module (CLIO : CommandLineIOAlg), module (DL : DomainLogicAlg)) => {
let cliInterface = CLIO.on(CLIO.make(), "close", DL.cleanup)
let close = () => CLIO.close(cliInterface)
let prompt = async () => await CLIO.prompt(cliInterface, "\u03BB> ", DL.handleUserInput)
let rec run_loop: () => Promise.t<cont_or_close> = async () => {
let cont_or_close = await prompt()
await handle_cont_or_close(cont_or_close, run_loop, close)
}
await run_loop()
}
src/RunRepl.res
open NodeJs
open CommandLineIOAlg
open DomainLogicAlg
open NewRepl
@module("fs") external unlinkSync: string => () = "unlinkSync"
// Here, the library author is the end user... so this is really just putting it all together
// so that package will work as advertised.
// ******************************************
// CommandLineIO instance
// ******************************************
module CommandLineIOAlg = {
type t = Readline.Interface.t
let make = () =>
Readline.make(
Readline.interfaceOptions(~input=Process.process->Process.stdin, ~output=Process.process->Process.stdout, ()),
)->CommandLineIO
let prompt = (CommandLineIO(rl), query, cb) =>
Promise.make((resolve, _reject) => rl->Readline.Interface.question(query, x => resolve(. x)))
->Promise.then(user_input => cb(user_input))
let on = (CommandLineIO(rl), event, cb) =>
rl->Readline.Interface.on(Event.fromString(event), cb)->CommandLineIO
let close = (CommandLineIO(rl)) => rl->Readline.Interface.close
}
// ******************************************
// Rescript REPL Specific Domain Logic instance
// ******************************************
module DomainLogicAlg = {
let handleUserInput = (s: string) => {
Promise.make((resolve, _reject) => resolve(. Close))
}
let cleanup = () => {
unlinkSync("./src/RescriptRepl.res")
unlinkSync("./src/RescriptRepl.bs.js")
()
}
}
let run_repl = () => repl(module (CommandLineIOAlg), module (DomainLogicAlg))
With this in place, the basic REPL flow is setup. In the next part, we'll write parser combinators to parse user input into a REPLCommand in order to determine what actions should be taken.