Adding builtin predicates via an OCaml plugin
Everything in the previous tutorials extends Rocq from within Elpi: new
commands, tactics and Db-backed state are all written in Elpi itself,
loaded by the rocq-elpi plugin that ships with Rocq-Elpi. Sometimes,
though, you want to give Elpi a new builtin data type or predicate that
is implemented in OCaml directly -- typically to wrap an existing OCaml
library, to get at data structures or algorithms that would be awkward to
write in Elpi, or for performance.
This chapter walks through a complete, minimal scaffold for exactly that: a
standalone opam package, built on top of rocq-elpi, that adds a new
Elpi data type myType and a new predicate compute implemented in
OCaml. The full, up to date source lives in the repository under
examples/example_plugin;
this chapter reproduces and explains it.
Project layout
A plugin is its own dune package, separate from the project(s) that will use it:
example_plugin/
dune-project
myExtension.opam (generated by dune from dune-project)
myExtension-tests.opam (generated by dune from dune-project)
src/
dune
my_extension_plugin.mlpack
my_extension_ocaml_code.ml -- the OCaml side of the new builtins
theories/
dune
Loader.v -- loads the plugin into Rocq
elpi/
dune
my-extension-elpi-code.elpi -- companion Elpi library code
doc/
dune
gen_doc.ml -- generates myExtension.elpi (reference doc)
tests/
dune
test.v -- exercises the new builtins from Rocq
dune-project declares the package(s) and their dependency on
rocq-elpi:
(lang dune 3.13)
(using coq 0.8)
(name Ext)
(generate_opam_files true)
(source (github username/reponame))
(authors "Author Name <author@example.com>")
(maintainers "Maintainer Name <maintainer@example.com>")
(license LICENSE)
(documentation https://url/to/documentation)
(package
(name myExtension)
(synopsis "myExtension adds extra Elpi datatypes and APIs")
(description "This package provides some extra goodies")
(depends rocq-elpi))
(package
(name myExtension-tests)
(synopsis "myExtension tests - do not install")
(description "myExtension tests - do not install")
(depends rocq-elpi))
Splitting the tests into their own package keeps them from being pulled in
(and built) by users who just want to use the plugin, matching the
convention already used by rocq-elpi itself (see rocq-elpi.opam and its
sibling test packages).
The OCaml side: a new data type and a new predicate
The new builtins live in an ordinary OCaml library, src/, which
depends on the elpi package (the Elpi library itself, not rocq-elpi
-- new builtins are plain Elpi builtins, they don't need anything
Rocq-specific unless they manipulate Rocq terms):
; src/dune
(library
(name myExtension_plugin)
(public_name myExtension.plugin)
(libraries elpi))
src/my_extension_ocaml_code.ml is where the actual work happens. First,
an ordinary OCaml algebraic data type:
module API = Elpi.API
module E = API.RawData
(* An algebraic data type with the API we want to expose: myType and compute *)
type myType = Constant : int -> myType | SumOf : (myType * myType) -> myType
let rec compute (s : myType) = match s with
| Constant n -> n
| SumOf (s1, s2) -> compute s1 + compute s2
Then, the Elpi-facing declarations. First the data type itself, via
AlgebraicData.declare, which builds the embed/readback functions
translating between the OCaml type and its Elpi representation
automatically from a description of its constructors:
(* Declaring the elpi symbol corresponding to the constructors of myType
is optional, only needed if one uses the RawData APIs *)
let constant = E.Constants.declare_global_symbol "constant"
let sumof = E.Constants.declare_global_symbol "sumof"
(* declaring the embed/readback function linking OCaml and Elpi *)
let myType : myType API.Conversion.t = API.(AlgebraicData.declare {
ty = TyName "myType";
doc = "description for the new elpi type";
pp = (fun fmt _ -> Format.fprintf fmt "<todo>");
constructors = [
K("constant","",A(BuiltInData.int, N),
B (fun x -> Constant x),
M (fun ~ok ~ko t -> match t with Constant n -> ok n | _ -> ko ()));
K("sumof","",S(S(N)),
B (fun x y -> SumOf (x, y)),
M (fun ~ok ~ko t -> match t with SumOf (x,y) -> ok x y | _ -> ko ()))
]
} |> ContextualConversion.(!<))
Each constructor is described by a K entry: its Elpi-visible name, a
docstring, the shape of its arguments (A for an atomic argument of a
given type, S for a sub-term of the type itself, N to terminate the
argument list), a B uild function (OCaml constructor -> the value) and a
M atch function (the value -> OCaml constructor, or failure via ko).
Next, the new predicate itself, via MLCode/Pred:
(* declaring a new API *)
let compute_api =
API.BuiltIn.MLCode(Pred("compute",
In(myType, "Expression",
Out(myType, "Result",
Easy("Result is the normal form of Expression"))),
(* The implementation of the API is the result of compute *)
(fun a _ ~depth:_ -> (), Some (Constant (compute a)))),
DocAbove)
In/Out describe the predicate's argument modes (matching the
-> notation used throughout this manual's own term/etc. API
documentation), each paired with the Conversion.t value that knows how
to translate it and a short docstring; the last argument to In/Out
is either another mode-annotated argument or, once the signature is
complete, the actual OCaml implementation together with its own docstring.
Finally, both are registered together under the name of the .elpi file
they should appear to come from (this is purely for documentation and
error-message purposes -- nothing is actually read from disk):
(* we declare the name of the file in which the new builtins are declared
and the exported stuff (data types and predicates) *)
let builtins = API.BuiltIn.declare ~file_name:"myExtension.elpi" [
MLData myType;
compute_api
]
Loading the plugin from Rocq
The plugin is an OCaml findlib library, so Rocq needs to be told to load it, exactly like any other Rocq plugin -- this is the one place that is Rocq-specific:
; theories/dune
(coq.theory
(name myExtension)
(theories elpi_elpi elpi myExtension.lib)
(plugins myExtension.plugin)
(package myExtension))
(include_subdirs qualified)
(* theories/Loader.v *)
Declare ML Module "myExtension.plugin".
From elpi Require Import elpi.
From myExtension.lib Extra Dependency "my-extension-elpi-code.elpi" as myExtensionLib.
A downstream user of the plugin just needs From myExtension Require
Import Loader. to get both the OCaml builtins and (see below) the
companion Elpi library code declared as an accumulable dependency.
A companion Elpi library
Not everything needs to be implemented in OCaml: it is often convenient to
ship a small Elpi library alongside the OCaml builtins, for code that is
just as easily (or more maintainably) written directly in Elpi. Here, a
tiny library converting between Elpi integers and Rocq's unary nat:
; elpi/dune
(coq.theory
(name myExtension.lib)
(theories elpi)
(plugins myExtension.plugin)
(package myExtension))
(include_subdirs qualified)
; this rule is needed for loading elpi files as extra deps
(rule
(target dummy.v)
(deps (glob_files *.elpi))
(action
(with-stdout-to %{target}
(progn (run rocq_elpi_shafile %{deps})))))
func int2nat int -> term.
int2nat 0 {{0}} :- !.
int2nat N {{S lp:R}} :- N > 0, N' is N - 1, int2nat N' R.
func nat2int term -> int.
nat2int {{0}} 0.
nat2int {{S lp:R}} N :- nat2int R N', N is N' + 1.
This is exactly the Extra Dependency/Elpi Accumulate File mechanism
described in Writing commands in Elpi: the .elpi file is declared as an
accumulable dependency (myExtensionLib, in Loader.v above) by the
plugin's own theories, and downstream code accumulates it by name -- see
tests/test.v below.
Generating reference documentation for the new builtins
The same mechanism this manual itself uses to link coq.say and
friends to their declaration (see Builtins and relevant files) is available
to plugins: API.BuiltIn.document_file renders every declared builtin's
docstring into a .elpi file with the same pred/data syntax as
builtin-doc/coq-builtin.elpi.
(* doc/gen_doc.ml *)
let _ = Elpi.API.BuiltIn.document_file ~header:"% Generated"
MyExtension_plugin.My_extension_ocaml_code.builtins
; doc/dune
(executable
(name gen_doc)
(libraries myExtension.plugin))
(rule
; myExtension.elpi contains the doc of the builtins defined in the myExtension plugin
(targets myExtension.elpi)
(deps gen_doc.exe)
(mode promote)
(action (run ./gen_doc.exe)))
Running this (dune build doc/myExtension.elpi, or just dune build,
since (mode promote) writes the result back into the source tree)
produces:
% description for the new elpi type
builtin data myType.
builtin symb constant : int -> myType = "1".
builtin symb sumof : myType -> myType -> myType = "1".
% [compute Expression Result] Result is the normal form of Expression
builtin func compute myType -> myType.
Using the plugin
Downstream code loads the plugin's loader, the companion Elpi library
(via its Extra Dependency name), and the generated documentation file
(via Elpi Accumulate Plugin, which loads a .elpi file that was
declared through BuiltIn.declare's file_name rather than through
Extra Dependency):
; tests/dune
(coq.theory
(name myExtension.tests)
(package myExtension-tests)
(theories elpi_elpi elpi myExtension myExtension.lib))
(include_subdirs qualified)
(* tests/test.v *)
From elpi Require Import elpi.
From myExtension Require Import Loader.
From myExtension.lib Extra Dependency "my-extension-elpi-code.elpi" as myExtensionLib.
Elpi Command test.
Elpi Accumulate Plugin "myExtension.elpi".
Elpi Accumulate File myExtensionLib.
Elpi Accumulate lp:{{
func encode term -> myType.
encode {{lp:X + lp:Y}} (sumof X1 Y1) :- encode X X1, encode Y Y1, !.
encode T (constant N) :- nat2int T N, !.
encode T _ :- coq.error "This Rocq test does not fit myType" T.
func decode myType -> term.
decode (constant N) T :- int2nat N T, !.
decode T _ :- coq.error "Not in normal form" T.
}}.
Elpi Query lp:{{
encode {{2 + 3}} X,
compute X Z,
decode Z {{ 5 }}.
}}.
This command converts the Rocq term 2 + 3 into myType (sumof
(constant 2) (constant 3)), calls the new OCaml-implemented compute
predicate to normalize it to constant 5, and converts that back into
the Rocq term 5 -- exercising the data type conversion, the OCaml
predicate, and the companion Elpi library all in one query.
Building and testing
Being an ordinary dune package, the scaffold builds and tests the usual
way, from the example_plugin directory:
$ dune build
$ dune build @runtest