Part 2: Creating a REPL for Rescript using Tagless Final
Implementing Parser Combinators to parse REPL Commands
We'll need two small utility functions:
src/utils/Utils.res
let id = x => x
let pipe = (g, f, x) => x->g->f
let compose = (f, g, x) => x->g->f
Next create a Functor interface, I won't cover the specifics of this implementation here, but if you're interesting in learning more, then check out this blog post which helped me learn how these can be implemented in OCaml.
src/interfaces/Functor.res
open Utils
module type AT = {
type t<'a>
}
module type Functor = {
include AT
let fmap: ('a => 'b) => t<'a> => t<'b>
}
module TestFunctor = (F: Functor) => {
let test_id = x => F.fmap(id, x) == x
let test_compose = x => {
let f = x => mod(x, 2)
let g = x => x - 1
F.fmap(pipe(g, f))(x) == F.fmap(f, (F.fmap(g, x)))
}
}
Next, an Applicative interface.
src/interfaces/Applicative.res
open Utils
open Functor
// equivalent:
// f <$> x <*> y
// f->fmap(x)->apply(y)
module type Applicative = {
include Functor
let pure: 'a => t<'a>
let apply: t<('a => 'b)> => t<'a> => t<'b>
}
module TestApplicative = (A: Applicative) => {
let test_id = x => (A.pure(id)->A.apply(x)) == x
let test_hom = (f, x) => (A.pure(f)->A.apply(A.pure(x))) == A.pure(f(x))
// A.t<('a => 'b)> => 'a => bool
let test_interchange = (u, y) => (u->A.apply(A.pure(y))) == (A.pure(f => f(y))->A.apply(u))
// A.t<('a => 'b)> => A.t<('c => 'a)> => A.t<'c> => bool
let test_composition = (u, v, w) =>
(A.pure(compose)->A.apply(u)->A.apply(v)->A.apply(w)) == u->A.apply(v -> A.apply(w))
}
src/repl-commands/REPLCommands.res
type moduleName = string
type replCommand = RescriptCode | StartMultiLineMode | EndMultiLineMode | LoadModule(moduleName)
Then we'll use Functor and Applicative when creating our Parser type:
src/repl-commands-parser/Parser
open Functor
open Applicative
open REPLCommands
type parseResult<'a> = option<(string, 'a)>
type parser<'a> = Parser({ runParser: string => parseResult<'a> })
module ParserFunctor : (Functor with type t<'a> = parser<'a>) = {
type t<'a> = parser<'a>
let fmap = (f, Parser(p)) => {
Parser({ runParser: s => {
// run the provided parser, then run the function over the result returned from that parser.
switch p.runParser(s) {
| Some((remainingStr, result)) => Some((remainingStr, f(result)))
| None => None
}
}})
}
}
// let Parser(apply_after_transform_p) = {
// ParserApplicative.apply(
// // expect this after the first step...
// // [ ', and more', ("hi", comma) => Js.String.toUpperCase("hi") ++ comma ]
// ParserApplicative.fmap((hi, comma) => Js.String.toUpperCase(hi) ++ comma, str("hi")),
// str(",")
// )
// }
// Js.log(apply_after_transform_p.runParser("hi, and more"))
module ParserApplicative : (Applicative with type t<'a> = parser<'a>) = {
include ParserFunctor
let pure = p => Parser({ runParser: _ => None })
let apply = (Parser(pf): t<('a => 'b)>, Parser(p): t<'a>): t<'b> =>
Parser({ runParser: s => {
switch pf.runParser(s) {
| Some((remainingStr, f)) => {
switch p.runParser(remainingStr) {
| Some((remainingStr, result)) => Some((remainingStr, f(result)))
| None => None
}
}
| None => None
}
}})
}
let parseReplCommand = (Parser(p), s: string): parseResult<replCommand> =>
p.runParser(s)
Now, we can create parser combinators and combine them to match the string patterns we want to correlate to a repl command which we want to support.
src/repl-commands-parser/ParserCombinators.res
open Parser
// λ> (Js.String.slice(~from=2, ~to_=5, "abcdefg") == "cde")->Js.log
// λ> true
let splitAt = (s: string, idx: int): (string, string) => {
let x = Js.String.slice(~from=0, ~to_=idx, s)
let y = Js.String.slice(~from=idx, ~to_=Js.String.length(s), s)
(x, y)
}
// λ> matchStr(":{", ":{and the rest of the string")->Js.log
// λ> true
let matchStr = (stringToMatch: string, inputStr: string) =>
Js.String.slice(~from=0, ~to_=Js.String.length(stringToMatch), inputStr) == stringToMatch
// There's also string to char
// String.make(1, 'a') => "a"
// λ> charToString('a')->Js.log
// λ> a
let charToString = (c: char): string => String.make(1, c)
let char = (c: char): parser<string> =>
Parser({ runParser: s => {
let (x, remainingStr) = splitAt(s, 1)
(x == charToString(c)) ? Some((remainingStr, x)) : None
}})
// let pattern = "run"
// let s = "running"
// λ> Js.String.slice(~from=0, ~to_=Js.String.length(pattern), s)->Js.log
// λ> run
// Js.String.slice(~from=Js.String.length(pattern), ~to_=Js.String.length(s), s)->Js.log
// λ> ning
let str = (pattern: string): parser<string> =>
Parser({ runParser: s => {
let (x, remainingStr) = splitAt(s, Js.String.length(pattern))
matchStr(pattern, s) ? Some((remainingStr, x)) : None
}})
let space: parser<string> =
Parser({ runParser: s => {
let (x, remainingStr) = splitAt(s, 1)
(" " == x) ? Some((remainingStr, x)) : None
}})
let empty: parser<string> =
Parser({ runParser: s => {
("" == s) ? Some((s, s)) : None
}})
let rec collectUntil = (s: string, pattern: string): option<(string, string)> => {
let (x, remainingStr) = splitAt(s, 1)
x == pattern ? Some((x, remainingStr)) : contCollectUntil(x, remainingStr, pattern)
} and contCollectUntil = (collected: string, s: string, pattern: string) => {
if Js.String.length(s) == 0 {
None
} else {
let (x, remainingStr) = splitAt(s, 1)
x == pattern ? Some((x ++ remainingStr, collected)) : contCollectUntil(collected ++ x, remainingStr, pattern)
}
}
let takeUntil = (pattern: string): parser<string> =>
Parser({ runParser: s => {
switch collectUntil(s, pattern) {
| Some((remaining_str, matched_str)) => Some((remaining_str, matched_str))
| None => None
}
}})
let load_p: parser<replCommand> = {
((_, _, filename, ext) => LoadModule(filename ++ ext))
->ParserApplicative.fmap(str(":load"))
->ParserApplicative.apply(space)
->ParserApplicative.apply(takeUntil("."))
->ParserApplicative.apply(str(".res"))
}
With this code in place, we can now start testing to ensure we're creating a replCommand for each successfully parsed string across all of the cases we want to handle in the REPL. We'll use rescript-test.
./tests/repl-commands-parser/ParserCombinators_test.res
open Test
open TestUtils
open ParserCombinators
test("Successfully parses appropriate input for the ':load' command", () => {
let result = Parser.parseReplCommand(loadCommandP, ":load some_filename.res")
let expected = Some(("", REPLCommands.LoadModule("some_filename.res")))
equals(expected, result)
})
test("Fails to parse when input for the ':load' command consists of a file with any extension other than .res", () => {
let result = Parser.parseReplCommand(loadCommandP, ":load some_filename.txt")
let expected = None
equals(expected, result)
})
test("Successfully parses appropriate input for the ':{' (start multiline mode) command", () => {
let result = Parser.parseReplCommand(startMultiLineCommandP, ":{")
let expected = Some(("", REPLCommands.StartMultiLineMode))
equals(expected, result)
})
test("Fails to parse when input for the ':{' (start multiline mode) command is anything aside from ':{'", () => {
let result = Parser.parseReplCommand(startMultiLineCommandP, ":{z")
let expected = None
equals(expected, result)
})
test("Successfully parses appropriate input for the '}:' (end multiline mode) command", () => {
let result = Parser.parseReplCommand(endMultiLineCommandP, "}:")
let expected = Some(("", REPLCommands.EndMultiLineMode))
equals(expected, result)
})
test("Fails to parse when input for the '}:' (end multiline mode) command is anything aside from '}:'", () => {
let result = Parser.parseReplCommand(startMultiLineCommandP, "}:a")
let expected = None
equals(expected, result)
})
src/repl-logic/REPLLogic.res
open Parser
open ParserCombinators
open REPLCommands
// NOTE:
// Well the next day as I was looking at this again and the thought popped up in my mind...
// An Alternative instance for option can be used here to prevent having to throw the error for the invariant violation case...
// parseReplCommand(loadCommandP, s) <|> parseReplCommand(startMultiLineCommandP, s) <|> Parser.parseReplCommand(endMultiLineCommandP, s) <|> Some(RescriptCode(s))
let parseReplCommand = s => {
let xs = [
Parser.parseReplCommand(loadCommandP, s),
Parser.parseReplCommand(startMultiLineCommandP, s),
Parser.parseReplCommand(endMultiLineCommandP, s)
]
let ys = Js.Array.filter(x => Belt.Option.isSome(x), xs)
if Belt.Array.length(ys) == 0 {
REPLCommands.RescriptCode(s)
} else {
switch ys[0] {
| Some((_, x)) => x
| _ => Js.Exn.raiseError("INVARIANT VIOLATION: Impossible state, Nones were filtered out of the array prior to this section of the code")
}
}
}
let parseAndHandleActions = (s: string) => {
Promise.make((resolve, _reject) => {
switch parseReplCommand(s) {
| StartMultiLineMode => {
// TODO: Update state to reflect entering multiline mode
// will need to modify some state to indicate that future lines should be appended to a string stored on state
resolve(. DomainLogicAlg.Continue)
}
| EndMultiLineMode => {
// TODO: Update state to reflect exiting multiline mode and
// persist and build entered rescript code
// will need to retrieve appended string from state, append it to file and run the res:build/rollback procedure
resolve(. DomainLogicAlg.Continue)
}
| LoadModule(_filename) => {
// TODO: Load rescript file and all of the specified dependencies in their necessary order.
resolve(. DomainLogicAlg.Continue)
}
| RescriptCode(_s) => {
// TODO
resolve(. DomainLogicAlg.Continue)
}
}
})
}
Next we'll write tests to ensure that the final overall parse of the string the user inputs at the repl properly yields an output which we can pattern match and then act upon in parseAndHandleActions.
tests/repl-logic/REPLLogic_test.res
open Test
open TestUtils
open ParserCombinators
test("Successfully parses the ':load' command", () => {
let result = REPLLogic.parseReplCommand(":load some_filename.res")
let expected = REPLCommands.LoadModule("some_filename.res")
equals(expected, result)
})
test("Successfully parses the ':{' (start multiline mode) command", () => {
let result = REPLLogic.parseReplCommand(":{")
let expected = REPLCommands.StartMultiLineMode
equals(expected, result)
})
test("Successfully parses the '}:' (end multiline mode) command", () => {
let result = REPLLogic.parseReplCommand("}:")
let expected = REPLCommands.EndMultiLineMode
equals(expected, result)
})
test("All other string input yields the RescriptCode command", () => {
let result = REPLLogic.parseReplCommand("some user input that won't compile")
let expected = REPLCommands.RescriptCode("some user input that won't compile")
equals(expected, result)
})
And now the implementation of DomainLogicAlg in src/REPL.res is currently:
module DomainLogicAlg = {
let handleUserInput = (s: string) =>
REPLLogic.parseAndHandleActions(s)
let cleanup = () => {
try {
unlinkSync("./src/RescriptRepl.res")
unlinkSync("./src/RescriptRepl.bs.js")
()
} catch {
| _ => ()
}
}
}
With this in place, we can start to work on our implementation of DomainLogicAlg.handleUserInput/1 function.