Embedding and extending Elpi

Elpi is a library first: the elpi command-line tool (Getting started) is itself a thin client of the elpi OCaml package, and every host application, from a five-line script to coq-elpi (the largest one), drives the same API.

Embedding

Link with ocamlfind (-package elpi) or dune ((libraries elpi)). Driving the interpreter is a handful of calls, in order: Setup.init builds an interpreter equipped with a set of builtins; Parse.program and Parse.goal read a program and a query from source text; Compile.program, Compile.query and Compile.optimize produce a runnable executable; Execute.once (or Execute.loop, for a REPL) runs it. elpi_REPL.ml, the source of the elpi command itself, is the canonical minimal client: every flag aside, it is this sequence.

Extending: a builtin written in OCaml

The FFI shape of a builtin is MLCode (Pred (name, signature, function), doc): signature describes each argument with In/Out/InOut (direction) and a conversion (how an Elpi term becomes an OCaml value and back: string, int, list, BuiltInData.any, or one of your own), and the OCaml function receives the In/InOut arguments already converted and returns the Out/InOut ones. sys.file_exists, one of the simplest real builtins in Elpi’s own standard library, needs only one In and no output at all: its two outcomes are to succeed (return ()) or to fail by raising No_clause (an output argument that may or may not be produced is wrapped with the !: / ?: notation instead; see BuiltInPredicate.Notation in the API for the details):

  MLCode (Pred ("sys.file_exists",
    In  (string, "Path",
    Easy "is like [Sys.file_exists]. It succeeds if the file at [Path] exists"),
  (fun path ~depth:_ ->
    match Sys.file_exists path with
    | true -> ()
    | false -> raise No_clause
    | exception Sys_error e ->
      (* No recoverable errors are produced by Sys.file_exists *)
      Utils.error ("file_exists: " ^ e))),
  DocAbove);

A declaration list, built with BuiltIn.declare ~file_name, bundles one or more such predicates (and, optionally, plain doc strings or Elpi source via LPDoc/LPCode) into a Setup.builtins value, the same kind of value as Elpi.Builtin.std_builtins, and passed to Setup.init the same way, in a list alongside it:

(* A minimal Elpi embedding: registers one custom builtin (sys.file_exists,
   the same implementation as builtin.ml's own, shown above) alongside the
   standard library, then parses, compiles and runs a program's main goal. *)

module E = Elpi.API

let my_builtins =
  let open E.BuiltIn in
  let open E.BuiltInPredicate in
  let open E.BuiltInData in
  declare ~file_name:"my_builtins.elpi" [
    MLCode (Pred ("sys.file_exists",
      In  (string, "Path",
      Easy "is like [Sys.file_exists]. It succeeds if the file at [Path] exists"),
    (fun path ~depth:_ ->
      match Sys.file_exists path with
      | true -> ()
      | false -> raise No_clause
      | exception Sys_error e ->
        (* No recoverable errors are produced by Sys.file_exists *)
        E.Utils.error ("file_exists: " ^ e))),
    DocAbove);
  ]

let () =
  let elpi =
    E.Setup.init
      ~builtins:[Elpi.Builtin.std_builtins; my_builtins]
      ~file_resolver:(E.Parse.std_resolver ~paths:[] ())
      () in
  let program = E.Parse.program ~elpi ~file:"my_program.elpi" in
  let goal = E.Parse.goal ~elpi ~loc:(E.Ast.Loc.initial "cli") ~text:"main." in
  let prog = E.Compile.program ~flags:E.Compile.default_flags ~elpi program in
  let executable = E.Compile.optimize (E.Compile.query prog goal) in
  match E.Execute.once executable with
  | E.Execute.Success _ -> print_endline "Success"
  | E.Execute.Failure -> print_endline "Failure"
  | E.Execute.NoMoreSteps -> print_endline "NoMoreSteps"

Once my_builtins.elpi accumulates automatically wherever my_builtins is passed to Setup.init, my_program.elpi can call sys.file_exists like any other predicate, with no further wiring on the Elpi side.

Extending: a quotation

{{ }} (the default quotation) and {{:name }} (a named one, name any run of non-space characters) let a term be written in a different, custom syntax and turned into an Elpi term at compile time (Terms). A quotation is not built in: the host registers each one through API.Quotation.set_default_quotation or register_named_quotation, giving Elpi a parser from source text to a term. With none registered, {{ }} is the compile-time error “No default quotation”.

For its own testing the elpi command-line tool registers one named quotation, also called elpi, whose custom syntax is Elpi syntax itself: it parses the quoted text as an ordinary term and hands it back unevaluated. That is enough to show the mechanism, foreign-looking source turning into a term the surrounding program can inspect and build on, even with Elpi standing in for the foreign language:

code/quotation.elpi:

1% elpi_REPL.exe registers one named quotation, "elpi", for testing: it
2% parses its content as Elpi syntax and hands back the resulting term
3% unevaluated. A host application registers its own quotations (for its own
4% object language) through the OCaml API instead.
5
6main :- X = {{:elpi 1 + 2 }}, print X.
1 + 2

A quotation can also embed a piece of the surrounding Elpi program inside the foreign syntax, an antiquotation. There is no fixed antiquotation syntax; it is whatever the quotation’s own parser recognises. coq-elpi quotes Rocq’s term syntax and antiquotes back to Elpi with an lp: prefix, so that in prod "x" t x\ {{ nat -> lp:x * bool }} the lp:x splices the just-bound Elpi variable x into the quoted Rocq term.

Also worth knowing

A custom data type, an OCaml value that should look like a plain Elpi term rather than being converted through string/int/list, is declared with MLData: 'a Conversion.t -> declaration, giving Elpi a pair of functions to read a term back into the OCaml value and to embed it as a term. Extensible state, data threaded through compilation and execution that is not itself an Elpi term, such as a symbol table built while compiling, is a State.component, declared with API.State.declare_component and read and written through the state value every advanced FFI hook (Full, Read, …) receives.

The odoc API reference (linked from this manual’s sidebar) is the complete signature of every module mentioned here. Its landing page lists several libraries; only elpi (the Elpi.API module used throughout this chapter) is the public, supported one. The others, elpi.compiler, elpi.parser, elpi.runtime, elpi.util, elpi.lexer_config and elpi.trace.*, are Elpi’s own implementation, split into separate libraries for internal build reasons and documented there only incidentally; a host application should never depend on them directly. coq-elpi is the largest real-world embedding, using custom data types, quotations and extensible state alike to embed Rocq’s own term syntax into Elpi.