Bumping Ppxlib's AST to 5.2 [ppxlib-5-2]
- February 18, 2025
Ppxlib is a libary for building OCaml preprocessors. Users can generate OCaml code from OCaml code (derivers) or replace parts of OCaml code with other OCaml code (rewriters).
At the core of ppxlib is the OCaml parsetree; a data structure in the compiler that represents OCaml source code. Ppxlib makes a distinction between the source parsetree (based on the version of the OCaml compiler you are using) and ppxlib's parsetree (a copy of some parsetree from the compiler).
In ppxlib.0.36.0, the internal AST was bumped from the AST of OCaml 4.14.0, to the AST of OCaml 5.2.0. This post discusses some of the internal mechanisms that allows ppxlib to support multiple compilers and how this bump broke a lot of backwards compatibility.
Manual Migrations in Ppxlib
- February 18, 2025
It is possible to perform migrations between parsetrees manually using the ppxlib.ast package.
open Ppxlib
open Ast_builder.Make(struct let loc = Location.none end)
let pp_expr = Format.printf "%a%!" Pp_ast.(expression ?config:None)
let loc = Location.none
let str txt = { txt; loc }
module To_408 =
Ppxlib_ast.Convert (Ppxlib_ast.Js) (Ppxlib_ast__.Versions.OCaml_408)
The Ppxlib_ast.Js module is ppxlib's AST (since ppxlib.0.36.0 OCaml 5.2.0). We can use functions provided by the Convert functor to copy parts of the AST from one version to another. Usually there is very little difference between the versioned parsetrees.
let func arg body =
pexp_fun Nolabel None (ppat_var (str arg)) body
let add_expr = func "x" (func "y" (eapply (evar "Int.add") [ evar "x"; evar "y" ]))
The above excerpt of OCaml code produces an expression in the language. More accurately, a function that has two arguments. If we print the simplified AST we can see that the arguments are stored together in a list.
# pp_expr add_expr;;
Pexp_function
( [ { pparam_loc = __loc
; pparam_desc = Pparam_val ( Nolabel, None, Ppat_var "x")
}
; { pparam_loc = __loc
; pparam_desc = Pparam_val ( Nolabel, None, Ppat_var "y")
}
]
, None
, Pfunction_body
(Pexp_apply
( Pexp_ident (Ldot ( Lident "Int", "add"))
, [ ( Nolabel, Pexp_ident (Lident "x"))
; ( Nolabel, Pexp_ident (Lident "y"))
]
))
)
- : unit = ()
This is a relatively new feature of the parsetree, if we migrate the expression down to 4.08 we see it is represented differently. The output is a little too verbose to be readable, and there's no good way to show the AST, so you will just have to take my word.
let expr_408 = To_408.copy_expression add_expr
Function Arity in the OCaml Parsetree
- February 18, 2025
OCaml 5.2 introduced a new way to represent functions. Before 5.2, all functions were represented with single arity. Functions with multiple arguments would return functions as their body. For example:
let add x y = x + y
A two-argument add function would be represented in the parsetree as fun x -> fun y -> x + y. Using the nodes from the parsetree (with some details removed) it would be Pexp_fun (x, Pexp_fun (y, Pexp_apply ...)).
Now, since OCaml 5.2, functions can be expressed with their syntactic arity intact. The function add would look something like Pexp_function ([ x; y ], Pfunction_body (Pexp_apply ...)). Both arguments are stored as a list.
The constructor Pexp_function was not a new addition to the parsetree. It used to represent pattern-matching expressions like function A -> 1. This can now be expressed as a function body of Pfunction_cases.
Whilst these additions and modifications to the parsetree are arguably a better design, it has caused chaos in terms of ppxlib's reverse dependencies!
Coalescing Function Arguments
- February 18, 2025
In ppxlib 0.36.0, functions from Ast_builder will help ppx authors produce maximum arity functions. However, functions from Ast_helper will not do this.
let ast_helper_func arg body =
Ast_helper.Exp.fun_ Nolabel None (ppat_var (str arg)) body
let ast_helper_expr = ast_helper_func "x" (ast_helper_func "y" (eapply (evar "Int.equal") [ evar "x"; evar "y" ]))
Here we recreate our helper function to create function expressions using Ast_helper.Exp.fun_ instead. We can now show the difference between the expr expression and the ast_helper_expr expression.
# pp_expr add_expr;;
Pexp_function
( [ { pparam_loc = __loc
; pparam_desc = Pparam_val ( Nolabel, None, Ppat_var "x")
}
; { pparam_loc = __loc
; pparam_desc = Pparam_val ( Nolabel, None, Ppat_var "y")
}
]
, None
, Pfunction_body
(Pexp_apply
( Pexp_ident (Ldot ( Lident "Int", "add"))
, [ ( Nolabel, Pexp_ident (Lident "x"))
; ( Nolabel, Pexp_ident (Lident "y"))
]
))
)
- : unit = ()
Above, we see a function expression with arity two. Below, we see nested function expressions each with arity one.
# pp_expr ast_helper_expr;;
Pexp_function
( [ { pparam_loc = __loc
; pparam_desc = Pparam_val ( Nolabel, None, Ppat_var "x")
}
]
, None
, Pfunction_body
(Pexp_function
( [ { pparam_loc = __loc
; pparam_desc = Pparam_val ( Nolabel, None, Ppat_var "y")
}
]
, None
, Pfunction_body
(Pexp_apply
( Pexp_ident (Ldot ( Lident "Int", "equal"))
, [ ( Nolabel, Pexp_ident (Lident "x"))
; ( Nolabel, Pexp_ident (Lident "y"))
]
))
))
)
- : unit = ()
Ramifications for Ppxlib Users
- February 18, 2025
Many, many, many ppxes broke after migrating the ppxlib AST to 5.2. Mostly, this is due to pattern-matching. Downstream users of ppxlib frequently pattern-match directly against the parsetree. However, nodes such as Pexp_fun no longer exist and Pexp_function has completely different constructor arguments!
Patching all the ppxes has been quite difficult. Most ppxes only deal with single arguments at a time (e.g. Pexp_fun x) but now they must handle zero or more arguments at a time. (In the zero case, this will always correspond to a pattern-matching function).
Here is a non-exhaustive list of some of the ppxes that have been patched. It may useful for other ppx authors who wish to upgrade to ppxlib.0.36.0:
https://gitlab.com/o-labs/ppx_deriving_jsoo/-/merge_requests/1
https://gitlab.com/gopiandcode/ppx-inline-alcotest/-/merge_requests/3
https://gitlab.com/o-labs/ppx_deriving_encoding/-/merge_requests/4
This release has emphasised the tension between using pattern-matching on algebraic datatypes and wanting to reduce downstream breakages (e.g. using abstract datatypes). This tension is not new.
The Future of Ppxlib
- February 18, 2025
Ppxlib is not going anywhere anytime soon. We have committed to trying to keep the internal AST up to date with the latest OCaml release. In fact, there is already a PR for the 5.3 bump!
There have been many discussions about simplifying this process, but so far the time and effort to build a ppxlib that will result in fewer breakages is too high (see an attempt using views).
Thank you to Tarides and Jane Street for funding my time to work on this release and all the chaos it has caused.
Happy preprocessing!