L8 — Embedding a Session

Goal: give your program a command language, a variable namespace and a save format without writing any of them — by embedding the REPL instead of calling the library directly.

This is the crossing point with the other branch. Everything Lessons: at the prompt teaches is reachable from here, because it is the same object answering.

The second artifact

libraryDependencies += "it.grypho" %% "leonardo-repl" % "0.0.0+1-92b231cd-SNAPSHOT"

leonardo-repl is a separate artifact from leonardo, and the split is the reason a library consumer never resolves JLine. Take this one only if you want the command language; L1–L7 need nothing but leonardo.

import it.grypho.scala.leonardo.cli.Session

One method

val s = Session()
s.execute("2 + 2")
// res0: String = "4.0"

execute(line: String): String is the whole interface. String in, string out — it parses, dispatches, evaluates and formats, and hands you exactly the text a prompt would have printed.

That is a much smaller surface than L1–L7’s, and it buys a great deal you would otherwise write yourself.

What you get for free

A namespace, with assignment (:=, never bare = — that builds an equation):

s.execute("radius := 3")
// res1: String = "radius := 3.0"
s.execute("area := pi * radius^2")
// res2: String = "area := (3.14159 * (radius ^ 2.0))"
s.execute("area")
// res3: String = "28.27433"

Late-bound definitions, which an Environment could not hold (L3):

s.execute("f := sin(t) + t")
// res4: String = "f := (sin(t) + t)"
s.execute("derive(f, t)")
// res5: String = "(cos(t) + 1.0)"

And settings, parsed and applied from the same string channel:

s.execute("precision 10")
// res6: String = "precision = 10"
s.execute("1/3")
// res7: String = "0.3333333333"

State is in the object, and it is mutable

Session is the one stateful thing in this library. The expression tree is immutable, eval is pure, derive is memoised because it is pure — and then Session deliberately holds bindings, definitions and settings, and execute mutates them.

Two consequences worth stating plainly. One session per user: it is a conversation, so sharing one across concurrent users would interleave their namespaces. And order matters, which is what makes the next section necessary.

Persistence is two methods

script serialises the whole session as commands that reproduce it:

s.script
// res8: String = """precision 10
// colors dark
// pretty off
// latex off
// names off
// logic minmax
// logic symmetric off
// exact precision 30
// exact off
// radius := 3.0
// area := (3.141592653589793 * (radius ^ 2.0))
// f := (sin(t) + t)"""

That is not a dump format — it is input. Settings come first, in the order a replay needs them, then the bindings. Feed it to a fresh session and it rebuilds:

val restored = Session()
restored.load(s.script)
s.execute("area")          // the original session, at the precision now in force
// res10: String = "28.2743338823"
restored.execute("area")   // and the rebuilt one
// res11: String = "28.2743338823"

Digit for digit the same, and it is worth knowing why that is not free. Look at how pi appears in the script above: at full Double precision, not as the 3.14159 the session displays. A saved session must reproduce what the session computes, not what it showed you — so script renders every leaf value through a serialising path of its own rather than through toString, which is deliberately fixed at five decimals because it is also what the round-trip invariant re-parses.

Values and definitions go through the same path, so a number written as a binding and the same number inside a formula cannot disagree. (They did until F_0046: definitions were serialised by plain printing, so this very page answered 28.27431 after a reload where it had answered 28.27433 — which is how the defect was found.)

Session itself does no IO. There is no file path anywhere in the two methods above; script returns a String and load takes one. Writing that string to a file, a database row, a URL fragment or a browser’s localStorage is your decision, and the browser REPL and the terminal REPL make it differently from the same two methods.

The file-backed convenience does exist, in the companion rather than the class — Session.loadFile(session, path) and Session.saveFile(session, path) — which is exactly the boundary: the class stays testable and platform-neutral, and the one part that touches a disk is somewhere you can choose not to call.

Settings as data

settings hands you the same state as (command, argument) pairs:

s.settings
// res12: List[Tuple2[String, String]] = List(
//   ("precision", "10"),
//   ("colors", "dark"),
//   ("pretty", "off"),
//   ("latex", "off"),
//   ("names", "off"),
//   ("logic", "minmax"),
//   ("logic symmetric", "off"),
//   ("exact precision", "30"),
//   ("exact", "off")
// )

This is the list script writes its header from, not a second view that could disagree with it. The pairing is what makes it useful to a user interface: k -> v is the command that restores the setting, so a changed control goes back through execute as the line a user would have typed. That is how the browser REPL’s settings panel is built, and it owns no state of its own.

Building a read loop

Session.step is the dispatcher the terminal REPL uses, and it is public:

import it.grypho.scala.leonardo.cli.Session.step
step(s, Some("1 + 1"))
// res13: Option[String] = Some("2.0")
step(s, Some("quit"))
// res14: Option[String] = None
step(s, None)
// res15: Option[String] = None

None in means end-of-input; None out means stop. It also intercepts :load and :save, which is why it, and not execute, is what a loop should call. The whole of the terminal REPL’s logic is that function — everything else in repl() is JLine.

Two behaviours that will surprise you

Both were found by executing the Branch R lessons, and both bite a program driving a session in sequence.

solve binds its answer. It is not a report:

Session().execute("solve(2x + 6 = 0, x)")
// res16: String = "x := -3.0"

x is now -3 in that session, so a later command substitutes it. A script solving two problems must unset between them or the second starts from the first’s answer.

At the prompt, derive takes exactly one variable. The varargs derive(e, x, y) of L6 is library API with no grammar production:

Session().execute("derive(x^2*y, x, y)")
// res17: String = "parse error: [1.16] failure: ')' expected but ',' found"

A second derivative is written derive(derive(f, x), x). This is the clearest example of the two surfaces genuinely differing — the grammar is not a transcription of the API, and where they part the grammar is what a user’s saved script depends on.

Which surface should you use?

Not a matter of taste:

  • The library (L1–L7) when the expressions come from your program — generated, traversed, or built from data. You want types, not strings.
  • A Session when the expressions come from a person, and you would otherwise be writing a namespace, a settings system and a save format. You want their text answered.

They compose: a session is a stateful shell around the same evaluator, so nothing stops you reading a result out of one surface and building with the other.

Try it yourself

Write runAll(lines: List[String]): List[(String, String)] that feeds a fresh session every line and pairs each with its output, skipping the lines that print nothing.

Answers ```scala def runAll(lines: List[String]): List[(String, String)] = val session = Session() lines.map(l => l -> session.execute(l)).filter(_._2.nonEmpty) ``` ```scala runAll(List("a := 4", "b := a^2", "", "sqrt(4)", "b + 1")) // res18: List[Tuple2[String, String]] = List( // ("a := 4", "a := 4.0"), // ("b := a^2", "b := (a ^ 2.0)"), // ( // "sqrt(4)", // """(sqrt * 4.0) // note: 'sqrt' is not a function here; 'sqrt(...)' parses as a product with 'sqrt'""" // ), // ("b + 1", "17.0") // ) ``` One session across the whole list is the point — a fresh one per line would lose `a` before `b` needed it, which is the mutable-state lesson from the other side. Now look at what `sqrt(4)` returned. It evaluated to `4·sqrt` — L1's trap, since the grammar has no `sqrt` — **and the session said so**, appending a note to the same string as the answer. That is the diagnostic a `Parser.parse` caller does not get: the REPL reads the input *text*, spots a name written immediately before `(` that the grammar does not claim, and reports it. It cannot be folded into the value, because the value is a perfectly ordinary product. So a session is not merely a shell around the parser — for this one failure mode it is strictly safer than calling the library yourself.

What you learned

  • leonardo-repl is a second artifact; the split is why a library consumer never resolves JLine.
  • execute(line): String is the entire interface — and it brings a namespace, := assignment, late-bound definitions, settings and diagnostics with it.
  • Session is mutable and stateful, deliberately and uniquely: one per user, and order matters.
  • Persistence is script / load, and neither touches a disk. The file-backed pair lives in the companion, so the class stays platform-neutral — which is how the same code runs the terminal and browser REPLs.
  • settings is that state as data, the same list script’s header comes from, so a panel and a saved script cannot disagree.
  • Session.step is the read loop’s dispatcher, and returning None is how a loop ends.
  • solve binds its answer, and the prompt’s derive takes one variable where the library’s takes many. The grammar is not a transcription of the API.

Previous: L7 — Numbers · Next: L9 — Refusals · All lessons


This site uses Just the Docs, a documentation theme for Jekyll.