Files
wasmtime/cranelift/isle/docs/language-reference.md
Waleed Dahshan 688168b4d7 Fix a mistake in the language reference (#4352)
It is clear that the third rule does not contribute to the rewriting of the expression `(A (B (D 42)))` to `(C (D 42))` to `(E 42)`.
2022-06-29 12:17:41 -07:00

1480 lines
60 KiB
Markdown

# ISLE: Instruction Selection Lowering Expressions
This document will describe ISLE (Instruction Selection Lowering
Expressions), a DSL (domain-specific language) that we have developed
in order to help us express certain parts of the Cranelift compiler
backend more naturally. ISLE was first [described in RFC
#15](https://github.com/bytecodealliance/rfcs/pull/15) and now is used
by and lives in the Cranelift tree in
[cranelift/isle](https://github.com/bytecodealliance/wasmtime/tree/main/cranelift/isle).
Documentation on how ISLE is used in Cranelift can be found
[here](../../docs/isle-integration.md).
## Intro and Whirlwind Tour: DSL for Instruction Lowering
The goal of ISLE is to represent *instruction lowering patterns*. An
instruction lowering pattern is a specification that a certain
combination of operators in the IR (CLIF), when combined under certain
conditions, can be compiled down into a certain sequence of machine
instructions. For example:
- An `iadd` (integer add) operator can always be lowered to an x86
`ADD` instruction with two register sources.
- An `iadd` operator with one `iconst` (integer-constant) argument can
be lowered to an x86 `ADD` instruction with a register and an
immediate.
One could write something like the following in ISLE (simplified from
the real code [here](../codegen/src/isa/x64/lower.isle)):
```lisp
;; Add two registers.
(rule (lower (iadd x y))
(value_reg (add
(put_in_reg x)
(RegMemImm.Reg (put_in_reg y)))))
;; Add a register and an immediate.
(rule (lower (iadd x (simm32_from_value y))
(value_reg (add
(put_in_reg x)
;; `y` is a `RegMemImm.Imm`.
y)))
```
ISLE lets the compiler backend developer express this information in a
declarative way -- i.e., just write down a list of patterns, without
worrying how the compilation process tries them out -- and the ISLE
DSL compiler will convert this list of patterns into efficient Rust
code that becomes part of Cranelift.
The rest of this document will describe the semantics of the DSL
itself. ISLE has been designed to be a general-purpose DSL that can
apply to any sort of backtracking pattern-matching problem, and will
generate a decision tree in Rust that can call into arbitrary
interface code.
Separate documentation will describe how we have written *bindings*
and *helpers* to allow ISLE to be specifically used to write Cranelift
lowering patterns like the above. (TODO: link this documentation)
## Outline of This Document
This document is organized into the following sections:
* Term-Rewriting Systems: a general overview of how term-rewriting
systems work, how to think about nested terms, patterns and rewrite
rules, how they provide a general mechanism for computation, and how
term-rewriting is often used in a compiler-implementation context.
* Core ISLE: the foundational concepts of the ISLE DSL, building upon
a general-purpose term-rewriting base. Covers the type system (typed
terms) and how rules are written.
* ISLE with Rust: covers how ISLE provides an "FFI" (foreign function
interface) of sorts to allow interaction with Rust code, and
describes the scheme by which ISLE execution is mapped onto Rust
(data structures and control flow).[^1]
* ISLE Internals: describes how the ISLE compiler works. Provides
insight into how an unordered collection of rewrite rules are
combined into executable Rust code that efficiently traverses the
input and matches on it.
[^1]: One might call this the BRIDGE (Basic Rust Interface Designed
for Good Efficiency) to the ISLE, but unfortunately we missed the
chance to introduce that backronym when we wrote the initial
implementation.
## Background: Term-Rewriting Systems
*Note: this section provides general background on term-rewriting
systems that is useful to better understand the context for ISLE and
how to develop systems using it. Readers already familiar with
term-rewriting systems, or wishing to skip to details on ISLE's
version of term rewriting, can skip to the [next
section](#core-isle-a-term-rewriting-system).*
A [term-rewriting
system](https://en.wikipedia.org/wiki/Rewriting#Term_rewriting_systems),
or TRS, is a system that works by representing data as *terms* and
then applying *rules* to "rewrite" the terms. This rewrite process
continues until some application-specific end-condition is met, for
example until no more rules are applicable or until the term reaches a
"lowered" state by some definition, at which point the resulting term
is the system's output.
Term-rewriting systems are a general kind of computing system, at the
same level as (e.g.) Turing machines or other abstract computing
machines. Term-rewriting is actually Turing-complete, or in other
words, can express any program, if no limits are placed on term length
or recursion.[^2]
[^2]: In fact, the [lambda
calculus](https://en.wikipedia.org/wiki/Lambda_calculus)
introduced by Alonzo Church is actually a term-rewriting system
and was developed at the same time as Turing's concepts of
universal computation!
Why might one want to use a TRS rather than some other, more
conventional, way of computing an answer? One reason is that they are
highly applicable to *pattern-matching* problems: for example,
translating data in one domain to data in another domain, where the
translation consists of a bunch of specific equivalences. This is part
of why term-rewriting is so interesting in the compiler domain:
compiler backends work to lower certain patterns in the program (e.g.:
a multiply-add combination) into instructions that the target machine
provides (e.g.: a dedicated multiply-add instruction).
Term rewriting as a process also naturally handles issues of
*priority*, i.e. applying a more specific rule before a less specific
one. This is because the abstraction allows for multiple rules to be
"applicable", and so there is a natural place to reason about priority
when we choose which rule to apply. This permits a nice separation of
concerns: we can specify which rewrites are *possible* to apply
separately from which are *desirable* to apply, and adjust or tune the
latter (the "strategy") at will without breaking the system's
correctness.
Additionally, term rewriting allows for a sort of *modularity* that is
not present in hand-written pattern-matching code: the specific rules
can be specified in any order, and the term-rewriting engine "weaves"
them together so that in any state, when we have partially matched the
input and are narrowing down which rule will apply, we consider all
the related rules at once. Said another way: hand-written code tends
to accumulate a lot of nested conditionals and switch/match
statements, i.e., resembles a very large decision tree, while
term-rewriting code tends to resemble a flat list of simple patterns
that, when composed and combined, become that more complex tree. This
allows the programmer to more easily maintain and update the set of
lowering rules, considering each in isolation.
### Data: Nested Trees of Constructors
A term-rewriting system typically operates on data that is in a *tree*
form, or at least can be interpreted that way.[^3]
[^3]: In the most fundamental and mathematical sense, a TRS just
operates on a sequence of symbols, but we can talk about
structure that is present in those symbols in any well-formed
sequence. For example, we can define a TRS that only operates on
terms with balanced parentheses; then we have our tree.
In ISLE and hence in this document, we operate on terms that are
written in an
[S-expression](https://en.wikipedia.org/wiki/S-expression) syntax,
borrowed from the Lisp world. So we might have a term:
```lisp
(a (b c 1 2) (d) (e 3 4))
```
which we can write more clearly as the tree:
```lisp
(a
(b
c 1 2)
(d)
(e
3 4))
```
Each term consists of either a *constructor* (which looks like a
function call to Lisp-trained eyes) or a *primitive*. In the above,
the `(a ...)`, `(b ...)`, `(d)`, and `(e ...)` terms/subterms are
constructor invocations. A constructor takes some number of arguments
(its *arity*), each of which is itself a term. Primitives can be
things like integer, string, or boolean constants, or variable names.
Some term-rewriting systems have other syntax conventions: for
example, systems based on
[Prolog](https://en.wikipedia.org/wiki/Prolog) tend to write terms
like `a(b(c, 1, 2), d, e(3, 4))`, i.e., with the name of the term on
the outside of the parentheses. This is just a cosmetic difference to
the above, but we note it to make clear that the term structure is
important, not the syntax.
It may not be immediately clear how to use this data representation,
but we can give a small flavor here: if one defines *constructors* for
each instruction or operator in a compiler's intermediate
representation (IR), one can start to write expressions from that IR
as terms; for example:
```lisp
v1 = imul y, z
v2 = iadd x, v1
```
could become:
```lisp
(iadd x (imul y z))
```
This will become much more useful once we have rewrite rules to
perform transformations on the terms!
Representing an IR is, of course, just one possible use of term data
(albeit the original "MVP" that guided ISLE's design); there are many
others, too. Interested readers are encouraged to read more on, e.g.,
[Prolog](https://en.wikipedia.org/wiki/Prolog), which has been used to
represent logical predicates, "facts" in expert systems, symbolic
mathematical terms, and more.
### Rules: Left-hand-side Patterns, Right-hand-side Expressions
The heart of a term-rewriting system is in the set of *rules* that
actually perform the rewrites. The "program" itself, in a
term-rewriting DSL, consists simply of an unordered list of
rules. Each rule may or may not apply; if it applies, then it can be
used to edit the term. Execution consists of repeated application of
rules until some criteria are met.
A rule consists of two parts: the left-hand side (LHS), or *pattern*,
and right-hand side (RHS), or *expression*. The left-hand and
right-hand nomenclature comes from a common way of writing rules as:
```plain
A -> B ;; any term "A" is rewritten to "B"
(A x) -> (B (C x)) ;; any term (A x), for some x, is rewritten to (B (C x)).
(A _) -> (D) ;; any term (A _), where `_` is a wildcard (any subterm),
;; is rewritten to (D).
```
#### Left-hand Sides: Patterns
Each left-hand side is written in a pattern language that commonly has
a few different kinds of "matchers", or operators that can match
subterms:
* `(A pat1 pat2 ...)` matches a constructor `A` with patterms for each
of its arguments.
* `x` matches any subterm and captures its value in a variable
binding, which can be used later when we specify the right-hand side
(so that the rewrite contains parts of the original term).
* `_` is a wildcard and matches anything, without capturing it.
* Primitive constant values, such as `42` or `$Symbol`, match only if
the term is exactly equal to this constant.
These pattern-matching operators can be combined, so we could write,
for example, `(A (B x _) z)`. This pattern would match the term `(A (B
1 2) 3)` but not `(A (C 4 5) 6)`.
A pattern can properly be seen as a partial function from input term
to captured (bound) variables: it either matches or it doesn't, and if
it does, it provides specific term values for each variable binding
that can be used by the right-hand side.
A fully-featured term rewriting system usually has other operators as
well, for convenience: for example, "match already-captured value", or
"bind variable to subterm and also match it with subpattern", or
"match subterm with all of these subpatterns". But even the above is
powerful enough for Turing-complete term reduction, surprisingly; the
essence of term-rewriting's power is just its ability to trigger
different rules on different "shapes" of the tree of constructors in
the input, and on special cases for certain argument values.
Pattern-based term rewriting has a notable and important feature: it
typically allows *overlapping* rules. This means that more than one
pattern might match on the input. For example, the two rules:
```plain
(A (B x)) -> (C x)
(A _) -> (D)
```
could *both* apply to an input term `(A (B 1))`. The first rule would
rewrite this input to `(C 1)`, and the second rule would rewrite it to
`(D)`. Either rewrite would be an acceptable execution step under the
base semantics of most term-rewriting systems; ordinarily, the
*correctness* of the rewrite should not depend on which rule is
chosen, only possibly the "optimality" of the output (whatever that
means for the application domain in question) or the number of rewrite
steps to get there.
However, in order to provide a deterministic answer, the system must
somehow specify which rule will be applied in such a situation based
on precedence, or specificity, or some other tie-breaker. A common
heuristic is "more specific rule wins". We will see how ISLE resolves
this question below by using both this heuristic and an explicit
priority mechanism.[^4]
[^4]: Some term-rewriting systems actually elaborate the entire space
of possibilities, following *all* possible rule application
sequences / rewrite paths. For example, the *equality
saturation* technique
([paper](https://cseweb.ucsd.edu/~lerner/papers/popl09.pdf),
[example implementation
Egg](https://blog.sigplan.org/2021/04/06/equality-saturation-with-egg/))
builds a data structure that represents all equivalent terms
under a set of rewrite rules, from which a heuristic
(cost/goodness function) can be used to extract one answer when
needed.
#### Right-hand Sides: Rewrite Expressions
Given a rule whose pattern has matched, we now need to compute the
rewritten term that replaces the original input term. This rewrite is
specified by the right-hand side (RHS), which consists of an
*expression* that generates a new term. This expression can use parts
of the input term that have been captured by variables in the
pattern.
We have already seen a few examples of this above: simple term
expressions, with variables used in place of concrete subterms where
desired. A typical term-rewrite system allows just a few options in
the output expression:
* Terms, with sub-expressions as arguments;
* Constant primitives (`42` or `$Symbol`); and
* Captured variable values (`x`).
The options are more limited in expressions than in patterns (e.g.,
there are no wildcards) because a pattern is matching on a range of
possible terms while an expression must specify a specific rewrite
result.
### Rewrite Steps and Intermediate Terms
Now that we can specify rewrites via a list of rules, we can study how
the top-level execution of a term-rewriting system proceeds. Much of
the power of term-rewriting comes from the fact that rewrites can
*chain together* into a multi-step traversal through several
intermediate terms before the final answer is computed.
For a simple example, consider the following rules:
```plain
(A (B x)) -> (C x)
(C (D x)) -> (E x)
(C (F x)) -> (G x)
```
This set of rules will rewrite `(A (B (D 42)))` to `(C (D 42))`, then
to `(E 42)` (via the first and second rules respectively).
How is this useful? First, rewriting one term to another (here, `C` at
the top level) that in turn appears in the left-hand side of other
rules allows us to *factor* a "program" of term-rewriting rules in the
same way that imperative programs are factored into separate
functions.[^5] The usual advantages of a well-factored program, where
each problem is solved with a small step that "reduces to a previously
solved problem", apply here.
Second, repeating the rewrite step is actually what grants
term-rewriting its Turing-completeness: it allows for arbitrary
control flow.[^6] This might be useful in cases where, for example, a
term-rewriting program needs "loop" over a list of elements in the
input: it can recurse and use intermediate terms to store state.
While this full generality may not be used often in the
domain-specific applications of term-rewriting that emphasize its
pattern-matching (such as instruction selectors), the user should not
be afraid to define and use intermediate terms -- rewriting into them,
then defining additional rules to rewrite further -- when it helps to
factor common behavior out of multiple rules, or aids in conceptual
clarity.
[^5]: In fact, ISLE actually compiles rules for different top-level
pattern terms (`(A ...)` and `(C ...)` in the example) into
separate Rust functions, so factoring rules to use intermediate
terms can provide code-size and compile-time benefits for the
ISLE-generated Rust code as well.
[^6]: The [lambda calculus' reduction
rules](https://en.wikipedia.org/wiki/Lambda_calculus#Reduction)
are a good example of this.
### Application to Compilers: A Term is a Value; Rewrites are Lowerings
So far this has been a fairly abstract introduction to term-rewriting
systems as a general computing paradigm. How does this relate (or, how
is it commonly mapped) to the instruction-selection problem?
In a domain such as instruction selection, we manipulate terms that
represent computations described by an IR, and the terms are
eventually rewritten into terms that name specific machine
instructions. We can think of each term as having a denotational value
that that *is* that program value. Then, any rewrite is correct if it
preserves the denotational value of the term.
In other words, terms are just values, and rules specify alternative
ways to compute the same values. We might have rewrite rules that
correspond to common algebraic identities (`a + b` == `b + a`, and
`a + 0` == `a`), for example. The main sort of rewrite rule, however,
will be one that takes a machine-*independent* operator term and
rewrites it into a machine-*dependent* instruction term. For example:
```plain
(iadd a b) -> (isa.add_reg_reg a b)
(iadd a (iconst 0)) -> a
(iadd a (iconst n)) (isa.add_reg_imm a n)
```
These rules specify three ways to convert an IR `iadd` operator into
an ISA-specific instruction. Recall from above that in general, an
application of a term-rewriting system should not depend for
correctness on the order or choice of rule application: when multiple
rules are applicable, then any sequence of rewrites that ends in a
terminating state (a state with no further-applicable rules) should be
considered a "correct" answer.[^7] Here, this is true: if, for
example, we choose the register-plus-register form (the first rule)
for an `iadd` operation, but the second argument is actually an
`iconst`, then that is still valid, and the `iconst` will separately
be rewritten by some other rule that generates a constant into a
register. It simply may not be as efficient as the more specific third
rule (or second rule, if the constant is zero). Hence, rule ordering
and prioritization is nevertheless important for the quality of the
instruction selector.
[^7]: Note that this suggests an interesting testing strategy: we
could choose arbitrary (random) orders of lowering rules to
apply, or even deliberately worst-case orders according to some
heuristic. If we can differentially test programs compiled with
such randomized lowerings against "normally" compiled programs
and show that the results are always the same, then we have
shown that are lowering rules are "internally consistent",
without any other external oracle. This will have a similar
effect to
[wasm-mutate](https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-mutate),
but takes mutations implicitly from the pool of rules rather
than a separate externally-defined pool of mutations. This idea
remains future work.
## Core ISLE: a Term-Rewriting System
This section describes the core ISLE language. ISLE's core is a
term-rewriting system, with a design that very closely follows the
generic concepts that we have described above.
In the core language, ISLE's key departure from many other
term-rewriting systems is that it is *strongly typed*. A classical
term-rewriting system, especially one designed for instruction
rewriting, will typically have just one type of term, corresponding to
a "value" in the program. In contrast, ISLE is designed so that terms
can represent various concepts in a compiler backend: values, but also
machine instructions or parts of those instructions ("integer
immediate value encodable in machine's particular format"), or
abstract bundles of information with invariants or guarantees encoded
in the type system ("load that I can sink", "instruction that produces
flags").
ISLE's other key departure from many other systems is its first-class
integration with Rust, including a well-defined "FFI" mapping that
allows ISLE rules to call into Rust in both their patterns and
expressions, and to operate directly on types that are defined in the
surrounding Rust code. This allows for easy and direct embedding into
an existing compiler backend. We will cover this aspect more in the
next section, [ISLE to Rust](#isle-to-rust).
### Rules
ISLE allows the user to specify rewrite rules, with a syntax similar
in spirit to that shown above:
```lisp
(rule
;; left-hand side (pattern): if the input matches this ...
(A (B _ x) (C y))
;; ... then rewrite to this:
(D x y))
```
The pattern (left-hand side) is made up of the following match
operators:
* Wildcards (`_`).
* Integer constants (decimal/hex, positive/negative: `1`, `-1`,
`0x80`, `-0x80`) and boolean constants (`#t`, `#f`).
* constants imported from the embedding, of arbitrary type
(`$MyConst`).
* Variable captures and matches (bare identifiers like `x`; an
identifier consists of alphanumeric characters and underscores, and
does not start with a digit). The first occurrence of a variable `x`
captures the value; each subsequent occurrence matches on the
already-captured value, rejecting the match if not equal.
* Variable captures with sub-patterns: `x @ PAT`, which captures the
subterm in `x` as above but also matches `PAT` against the
subterm. For example, `x @ (A y z)` matches an `A` term and captures
its arguments as `y` and `z`, but also captures the whole term as
`x`.
* conjunctions of subpatterns: `(and PAT1 PAT2 ...)` matches all of
the subpatterns against the term. If any subpattern does not match,
then this matcher fails.
* Term deconstruction: `(term PAT1 PAT2 ...)`, where `term` is a
defined term (type variant or constructor) and the subpatterns are
applied to each argument value in turn. Note that `term` cannot be a
wildcard; it must be a specific, concrete term.
The expression (right-hand side) is made up of the following
expression operators:
* Integer and symbolic constants, as above.
* Boolean constants `#t` and `#f` (following Scheme syntax).
* Variable uses (bare `x` identifier).
* Term constructors (`(term EXPR1 EXPR2 ...)`, where each
subexpression is evaluated first and then the term is constructed).
* `let`-expressions that bind new variables, possibly using the values
multiple times within the body: `(let ((var1 type1 EXPR1) (var2 ...)
...) BODY ...)`. Each variable's initialization expression can refer
to the immediately previous variable bindings (i.e., this is like a
`let*` in Scheme). `let`s are lexically-scoped, meaning that bound
variables are available only within the body of the `let`.
When multiple rules are applicable to rewrite a particular term, ISLE
will choose the "more specific" rule according to a particular
heuristic: in the lowered sequence of matching steps, when one
left-hand side completes a match while another with the same prefix
continues with further steps, the latter (more specific) is chosen.
The more-specific-first heuristic is usually good enough, but when an
undesirable choice occurs, explicit priorities can be specified.
Rules with explicit priorities are written as `(rule PRIO lhs rhs)`
where `PRIO` is a signed (positive or negative) integer. An applicable
rule with a higher priority will always match before a rule with a
lower priority. The default priority for all rules if not otherwise
specified is `0`.
Note that the system allows multiple applicable rules to exist with
the same priority: that is, while the priority system allows for
manual tie-breaking, this tie-breaking is not required.
Finally, one important note: the priority system is considered part of
the core language semantics and execution of rules with different
priorities is well-defined, so can be relied upon when specifying
correct rules. However, the tie-breaking heuristic is *not* part of
the specified language semantics, and so the user should never write
rules whose correctness depends on one rule overriding another
according to the heuristic.
### Typed Terms
ISLE allows the programmer to define types, and requires every term to
have *parameter types* and a *return type* (analogous to first-order
functions).
The universe of types is very simple: there are *primitives*, which
can be integers or symbolic constants (imported from the Rust
embedding), and *enums*, which correspond direclty to Rust enums with
variants that have named fields. There is no subtyping. Some examples
of type definitions are:
```lisp
(type u32 (primitive u32)) ;; u32 is a primitive, and is
;; spelled `u32` in the generated Rust code.
(type MyType (enum
(A (x u32) (y u32))
(B (z u32))
(C))) ;; MyType is an enum, with variants
;; `MyType::A { x, y }`, `MyType::B { z }`,
;; and `MyType::C`.
(type MyType2 extern (enum (A)))
;; MyType2 is an enum with variant `MyType2::A`.
;; Its type definition is not included in the
;; generated Rust, but rather, assumed to exist
;; in surrounding code. Useful for binding to
;; existing definitions.
```
We then declare constructors with their parameter and return types as
follows:
```lisp
(decl Term1 (u32 u32) MyType) ;; Term1 has two `u32`-typed parameters,
;; and itself has type `MyType`.
(decl Term2 () u32) ;; Term2 has no parameters and type `u32`.
```
Note that when an enum type is defined, its variants are implicitly
defined as constructors as well. These constructors are namespaced
under the name of the type, to avoid ambiguity (or the need to do
type-dependent lookups in the compiler, which can complicate type
inference). For example, given the above `MyType` definitions, we
automatically have the following constructors:
```lisp
;; These definitions are implicit and do not need to be written (doing
;; so is a compile-time error, actually). We write them here just to
;; show what they would look like.
(decl MyType.A (u32 u32) MyType)
(decl MyType.B (u32) MyType)
(decl MyType.C () MyType)
(decl MyType2.A () MyType2)
```
### Why Types?
For terms that are not enum variants, the notion that a term "has a
type" is somewhat foreign to a classical term-rewriting system. In
most formal symbolic systems, the terms are manipulated as opaque
sequences or trees of symbols; they have no inherent meaning other
than what the user implicitly defines with the given rules. What does
it mean for a term to "have a type" when it is just data? Or, said
another way: why isn't the type of `Term2` just `Term2`?
The types come into play when we define *rules*: one term of type `T`
can only be rewritten into another term of type `T`, and when a
parameter has a certain type, only subterms with that type can
appear. Without explicit types on terms and their parameters, any term
could be rewritten to any other, or substituted in as a parameter, and
there is thus a kind of dynamic typing about which the programmer must
have some caution. In most applications of a term-rewriting system,
there is already some de-facto "schema": some parameter of a term
representing a machine instruction can only take on one of a few
subterms (representing, say, different addressing modes). ISLE's types
just make this explicit.
Thus, the first answer to "why types" is that they enforce a schema on
the terms, allowing the programmer to have stronger well-formed-data
invariants.
The second reason is that the types are an integral part of the
compilation-to-Rust strategy: every constructor actually does evaluate
to a Rust value of the given "return value" type, given actual Rust
values for its parameters of the appropriate parameter types. We will
see more on this below.
### Well-Typed Rules and Type Inference
Now that we understand how to define types, let's examine in more
detail how they are used to verify that the pattern and rewrite
expression of a rule have the same type.
ISLE uses a simple unidirectional type-inference algorithm that
propagates type information through the pattern, resulting in a "type
environment" that specifies the type for each captured variable, and
then uses this to typecheck the rewrite expression. The result of this
is that types are almost completely inferred, and are only annotated
in a few places (`let` bindings specifically).
The typing rules for patterns in ISLE are:
* At the root of the pattern, we require that a *constructor* pattern
is used, rather than some other match operation (a wildcard, integer
constant, etc.). This is because compilation and dispatch into rules
is organized by the top-level constructor of the term being
rewritten.
* At each part of the pattern except the root, there is an "expected
type" that is inferred from the surrounding context. We check that
this matches the actual type of the pattern.
* A constructor pattern `(C x y z)`, given a constructor `(decl C (T1
T2 T2) R)`, has type `R` and provides expected types `T1`, `T2`, and
`T3` to its subpatterns.
* A variable capture pattern `x` is compatible with any expected type
the first time it appears, and captures this expected type under the
variable identifier `x` in the type environment. Subsequent
appearances of `x` check that the expected type matches the
already-captured type.
* A conjunction `(and PAT1 PAT2 ...)` checks that each subpattern is
compatible with the expected type.
* Integer constants are compatible with any primitive expected
type. (This may change in the future if we add non-numeric
primitives, such as strings.)
If we are able to typecheck the pattern, we have a type environment
that is a map from variable bindings to types: e.g., `{ x: MyType, y:
MyType2, z: u32 }`. We then typecheck the rewrite expression.
* Every expression also has an expected type, from the surrounding
context. We check that the provided expression matches this type.
* The top-level rewrite expression must have the same type as the
top-level constructor in the pattern. (In other words, a term can
only be rewritten to another term of the same type.)
* Constructors check their return values against the expected type,
and typecheck their argument expressions against their parameter
types.
* A `let` expression provides types for additional variable bindings;
these are added to the type environment while typechecking the
body. The expected type for the body is the same as the expected
type for the `let` itself.
### A Note on Heterogeneous Types
We should illuminate one particular aspect of the ISLE type system
that we described above. We have said that a term must be rewritten to
another term of the same type. Note that this does *not* mean that,
for example, a set of ISLE rules cannot be used to translate a term of
type `T1` to a term of type `T2`. The trick is to define a top-level
"driver" that wraps the `T1`, such that reducing this term results in
a `T2`. Concretely:
```lisp
(type T1 ...)
(type T2 ...)
(decl Translate (T1) T2)
(rule (Translate (T1.A ...))
(T2.X ...))
(rule (Translate (T1.B ...))
(T2.Y ...))
```
This gets to the heart of rewrite-system-based computation, and has
relevance for applications of ISLE to compiler backends. A common
technique in rewrite systems is to "kick off" a computation by
wrapping a term in some intermediate term that then drives a series of
reductions. Here we are using `Translate` as this top-level term. A
difference between ISLE and some other rewrite-based instruction
selectors is that rewrites are always driven by term reduction from
such a toplevel term, rather than a series of equivalences directly
from IR instruction to machine instruction forms.
In other words, a conventional instruction selection pattern engine
might let one specify `(Inst.A ...) -> (Inst.X ...)`. In this
conventional design, the instruction/opcode type on the LHS and RHS
must be the same single instruction type (otherwise rewrites could not
be chained), and rewrite relation (which we wrote as `->`) is in
essence a single privileged relation. One can see ISLE as a
generalization: we can define many different types, and many different
toplevel terms from which we can start the reduction. In principle,
one could have:
```lisp
(type IR ...)
(type Machine1 ...)
(type Machine2 ...)
(decl TranslateToMachine1 (IR) Machine1)
(decl TranslateToMachine2 (IR) Machine2)
(rule (TranslateToMachine1 (IR.add a b)) (Machine1.add a b))
(rule (TranslateToMachine2 (IR.add a b)) (Machine2.weird_inst a b))
```
and then both translations are available. We are "rewriting" from `IR`
to `Machine1` and from `IR` to `Machine2`, even if rewrites always
preserve the same type; we get around the rule by using a constructor.
### Constructors and Extractors
So far, we have spoken of terms and constructors: a term is a schema
for data, like `(A arg1 arg2)`, while we have used the term
"constructor" to refer to the `A`, like a function. We now refine this
notion somewhat and define what it means for a term to appear in the
left-hand (pattern) or right-hand (expression) side of a rule.
More precisely, a term, like `A`, can have three kinds of behavior
associated with it: it can be an enum type variant, it can be a
constructor, or it can be an *extractor*, which we will define in a
moment. A term can be both an extractor and constructor
simultaneously, but the enum type variant case is mutually exclusive
with the others.
The distinction between a "constructor" and an "extractor" is whether
a term is being deconstructed (matched on) -- by an extractor -- or
constructed -- by a constructor.
#### Constructors
Constructor behavior on a term allows it to be invoked in the
right-hand side of a rule. A term can have either an "external
constructor" (see below) or an "internal constructor", defined in
ISLE. Any term `A` that has one or more `(rule (A ...) RHS)` rules in
the ISLE source implicitly has an internal constructor, and this
constructor can be invoked from the right-hand side of other rules.
#### Extractors
Extractor behavior on a term allows it to be used in a pattern in the
left-hand side of a rule. If one considers a constructor to be a
function that goes from argument values to the complete term, then an
extractor is a function that takes a complete term and possibly
matches on it (it is fallible). If it does match, it provides the
arguments *as results*.
One can see extractors as "programmable match operators". They are a
generalization of enum-variant deconstruction. Where a traditional
term-rewriting system operates on a term data-structure that exists in
memory, and can discover that a pattern `(A x y)` matches a term `A`
at a particular point in the input, an extractor-based system instead
sees `A` as an *arbitrary programmable operator* that is invoked
wherever a pattern-match is attempted, and can return "success" with
the resulting "fields" as if it were actually an enum variant. For
more on this topic, see the motivation and description in [RFC 15
under "programmable matching on virtual
nodes"](https://github.com/bytecodealliance/rfcs/blob/main/accepted/cranelift-isel-isle-peepmatic.md#extractors-programmable-matching-on-virtual-nodes).
To provide a concrete example, if we have the term declarations
```lisp
(decl A (u32 u32) T)
(decl B (T) U)
```
then if we write a rule like
```lisp
(rule (B (A x y))
(U.Variant1 x y))
```
then we have used `A` as an *extractor*. When `B` is invoked as a
constructor with some `T`, this rule uses `A` as an extractor and
attempts (via whatever programmable matching behavior) to use `A` to
turn the `T` into two `u32`s, binding `x` and `y`. `A` can succeed or
fail, just as any other part of a pattern-match can.
Just as for constructors, there are *internal* and *external*
extractors. Most of the interesting programmable behavior occurs in
external extractors, which are defined in Rust; we will discuss this
further in a section below. Internal extractors, in contrast, behave
like macros, and can be defined for convenience: for example, we can
write
```lisp
(decl A (u32 u32) T)
(extractor (A pat1 pat2)
(and
(extractArg1 pat1)
(extractArg2 pat2)))
```
which will, for example, expand a pattern `(A (subterm ...) _)` into
`(and (extractArg1 (subterm ...)) (extractArg2 _))`: in other words,
the arguments to `A` are substituted into the extractor body and then
this body is inlined.
#### Implicit Type Conversions
For convenience, ISLE allows the program to associate terms with pairs
of types, so that type mismatches are *automatically resolved* by
inserting that term.
For example, if one is writing a rule such as
```lisp
(decl u_to_v (U) V)
(rule ...)
(decl MyTerm (T) V)
(rule (MyTerm t)
(u_to_v t))
```
the `(u_to_v t)` term would not typecheck given the ISLE language
functionality that we have seen so far, because it expects a `U` for
its argument but `t` has type `T`. However, if we define
```lisp
(convert T U t_to_u)
;; For the above to be valid, `t_to_u` should be declared with the
;; signature:
(decl t_to_u (T) U)
(rule ...)
```
then the DSL compiler will implicitly understand the above `MyTerm` rule as:
```lisp
(rule (MyTerm t)
(u_to_v (t_to_u t)))
```
This also works in the extractor position: for example, if one writes
```lisp
(decl defining_instruction (Inst) Value)
(extern extractor definining_instruction ...)
(decl iadd (Value Value) Inst)
(rule (lower (iadd (iadd a b) c))
...)
(convert Inst Value defining_instruction)
```
then the `(iadd (iadd a b) c)` form will be implicitly handled like
`(iadd (defining_instruction (iadd a b)) c)`. Note that the conversion
insertion needs to have local type context in order to find the right
converter: so, for example, it cannot infer a target type from a
pattern where just a variable binding occurs, even if the variable is
used in some typed context on the right-hand side. Instead, the
"inner" and "outer" types have to come from explicitly typed terms.
#### Summary: Terms, Constructors, and Extractors
We start with a `term`, which is just a schema for data:
```lisp
(decl Term (A B C u32 u32) T)
```
A term can have:
1. A single internal extractor body, via a toplevel `(extractor ...)`
form, OR
2. A single external extractor binding (see next section); AND
3. One or more `(rule (Term ...) ...)` toplevel forms, which together
make up an internal constructor definition, OR
4. A single external constructor binding (see next section).
### If-Let Clauses
As an extension to the basic left-hand-side / right-hand-side rule
idiom, ISLE allows *if-let clauses* to be used. These add additional
pattern-matching steps, and can be used to perform additional tests
and also to use constructors in place of extractors during the match
phase when this is more convenient.
To introduce the concept, an example follows (this is taken from the
[RFC](https://github.com/bytecodealliance/rfcs/tree/main/isle-extended-patterns.md)
that proposed if-lets):
```lisp
;; `u32_fallible_add` can now be used in patterns in `if-let` clauses
(decl pure u32_fallible_add (u32 u32) u32)
(extern constructor u32_fallible_add u32_fallible_add)
(rule (lower (load (iadd addr
(iadd (uextend (iconst k1))
(uextend (iconst k2))))))
(if-let k (u32_fallible_add k1 k2))
(isa_load (amode_reg_offset addr k)))
```
The key idea is that we allow a `rule` form to contain the following
sub-forms:
```lisp
(rule LHS_PATTERN
(if-let PAT2 EXPR2)
(if-let PAT3 EXPR3)
...
RHS)
```
The matching proceeds as follows: the main pattern (`LHS_PATTERN`)
matches against the input value (the term to be rewritten), as
described in detail above. Then, if this matches, execution proceeds
to the if-let clauses in the order they are specified. For each, we
evaluate the expression (`EXPR2` or `EXPR3` above) first. An
expression in an if-let context is allowed to be "fallible": the
constructors return `Option<T>` at the Rust level and can return
`None`, in which case the whole rule application fails and we move on
to the next rule as if the main pattern had failed to match. (MOre on
the fallible constructors below.) If the expression evaluation
succeeds, we match the associated pattern (`PAT2` or `PAT3` above)
against the resulting value. This too can fail, causing the whole rule
to fail. If it succeeds, any resulting variable bindings are
available. Variables bound in the main pattern are available for all
if-let expressions and patterns, and variables bound by a given if-let
clause are available for all subsequent clauses. All bound variables
(from the main pattern and if-let clauses) are available in the
right-hand side expression.
#### Pure Expressions and Constructors
In order for an expression to be used in an if-let clause, it has to
be *pure*: it cannot have side-effects. A pure expression is one that
uses constants and pure constructors only. Enum variant constructors
are always pure. In general constructors that invoke function calls,
however (either as internal or external constructor calls), can lead
to arbitrary Rust code and have side-effects. So, we add a new
annotation to declarations as follows:
```lisp
;; `u32_fallible_add` can now be used in patterns in `if-let` clauses
(decl pure u32_fallible_add (u32 u32) u32)
;; This adds a method
;; `fn u32_fallible_add(&mut self, _: u32, _: u32) -> Option<u32>`
;; to the `Context` trait.
(extern constructor u32_fallible_add u32_fallible_add)
```
The `pure` keyword here is a declaration that the term, when used as a
constructor, has no side-effects. Declaring an external constructor on
a pure term is a promise by the ISLE programmer that the external Rust
function we are naming (here `u32_fallible_add`) has no side-effects
and is thus safe to invoke during the match phase of a rule, when we
have not committed to a given rule yet.
When an internal constructor body is generated for a term that is pure
(i.e., if we had `(rule (u32_fallible_add x y) ...)` in our program
after the above declaration instead of the `extern`), the right-hand
side expression of each rule that rewrites the term is also checked
for purity.
#### `if` Shorthand
It is a fairly common idiom that if-let clauses are used as predicates
on rules, such that their only purpose is to allow a rule to match,
and not to perform any destructuring with a sub-pattern. For example,
one might want to write:
```lisp
(rule (lower (special_inst ...))
(if-let _ (isa_extension_enabled))
(isa_special_inst ...))
```
where `isa_extension_enabled` is a pure constructor that is fallible,
and succeeds only when a condition is true.
To enable more succinct expression of this idiom, we allow the
following shorthand notation using `if` instead:
```lisp
(rule (lower (special_inst ...))
(if (isa_extension_enabled))
(isa_special_inst ...))
```
## ISLE to Rust
Now that we have described the core ISLE language, we will document
how it interacts with Rust code. We consider these interactions to be
semantically as important as the core language: they are not
implementation details, but rather, a well-defined interface by which
ISLE can interface with the outside world (an "FFI" of sorts).
### Mapping to Rust: Constructors, Functions, and Control Flow
ISLE was designed to have a simple, easy-to-understand mapping from
its language semantics to Rust semantics. This means that the
execution of ISLE rewriting has a well-defined implementation in
Rust. The basic principles are:
1. Every term with rules in ISLE becomes a single Rust function. The
arguments are the Rust function arguments. The term's "return
value" is the Rust function's return value (wrapped in an `Option`
because pattern coverage can be incomplete).
2. One rewrite step is one Rust function call.
3. Rewriting is thus eager, and reified through ordinary Rust control
flow. When we construct a term that appears on the left-hand side
of rules, we do so by calling a function (the "constructor body");
and this function *is* the rewrite logic, so the term is rewritten
as soon as it exists. The code that embeds the ISLE generated code
will kick off execution by calling a top-level "driver"
constructor. The body of the constructor will eventually choose one
of several rules to apply, and execute code to build the right-hand
side expression; this can invoke further constructors for its
subparts, kicking off more rewrites, until eventually a value is
returned.
4. This design means that "intermediate terms" -- constructed terms
that are then further rewritten -- are never actually built as
in-memory data-structures. Rather, they exist only as ephemeral
stack-frames while the corresponding Rust function executes. This
means that there is very little or no performance penalty to
factoring code into many sub-rules (subject only to function-call
overhead and/or the effectiveness of the Rust inliner).
5. Backtracking -- attempting to match rules, and backing up to follow
a different path when a match fails -- exists, but is entirely
internal to the generated Rust function for rewriting one
term. Once we are rewriting a term, we have committed to that term
existing as a rewrite step; we cannot backtrack further. However,
backtracking can occur within the delimited scope of this one
term's rewrite; we have a phase during which we evaluate left-hand
sides, trying to find a matching rule, and once we find one, we
commit and start to invoke constructors to build the right-hand
side.
Said another way, the principle is that left-hand sides are
fallible, and have no side-effects as they execute; right-hand
sides, in contrast, are infallible. This simplifies the control
flow and makes reasoning about side-effects (especially with
respect to external Rust actions) easier.
This will become more clear as we look at how Rust interfaces are
defined, and how the generated code appears, below.
### Extern Constructors and Extractors
ISLE programs interact with the surrounding Rust code in which they
are embedded by allowing the programmer to define a term to have
*external constructors* and an *external extractor*.
The design philosophy of ISLE is that while internally it operates as
a fairly standard term-rewriting system, on the boundaries the "terms"
should be virtual, and defined procedurally rather than reified into
data structures, in order to allow for very flexible binding to the
embedding application. Thus, when term-rewriting bottoms out on the
input side, it just calls "extractors" to match on whatever ultimate
input the user provides, and these are fully programmable; and when it
bottoms out on the output side, the "term tree" is reified as a tree
of Rust function calls rather than plain data.
#### Constructors
As we defined above, a "constructor" is a term form that appears in an
expression and builds its return value from its argument
values. During the rewriting process, a constructor that can trigger
further rewriting rules results in a Rust function call to the body of
the "internal constructor" built from these rules; the term thus never
exists except as argument values on the stack. However, ultimately the
ISLE code needs to return some result to the outside world, and this
result may be built up of many parts; this is where *external
constructors* come into play.
For any term declared like
```lisp
(decl T (A B C) U)
```
the programmer can declare
```lisp
(extern constructor T ctor_func)
```
which means that there is a Rust function `ctor_func` on the context
trait (see below) that can be *invoked* with arguments of type `A`,
`B`, `C` (actually borrows `&A`, `&B`, `&C`, for non-primitive types)
and returns a `U`.
External constructors are infallible: that is, they must succeed, and
always return their return type. In contrast, internal constructors
are fallible because they are implemented by a list of rules whose
patterns may not cover the entire domain. If fallible behavior is
needed when invoking external Rust code, that behavior should occur in
an extractor (see below) instead: only pattern left-hand sides are
meant to be fallible. Note, incidentally, that fallibility in a
constructor does not backtrack to try other rules; instead it just
trickles the `None` failure all the way up.
#### Extractors
An *external extractor* is an implementation of matching behavior in
left-hand sides (patterns) that is fully programmable to interface
with the embedding application. When the generated pattern-matching
code is attempting to match a rule, and has a value to match against
an extractor pattern defined as an external extractor, it simply calls
a Rust function with the value of the term to be deconstructed, and
receives an `Option<(arg1, arg2, ...)>` in return. In other words, the
external extractor can choose to match or not, and if it does, it
provides the values that are in turn matched by sub-patterns.
For any term declared like
```lisp
(decl T (A B C) U)`
```
the programmer can declare
```lisp
(extern extractor T etor_func)
```
which means that there is a Rust function `etor_func` on the context
trait (see below) that can be *invoked* with an argument of type `&U`,
and returns an `Option<(A, B, C)>`.
If an extractor returns `None`, then the generated matching code
proceeds just as if an enum variant match had failed: it moves on to
try the next rule in turn.
### Mapping Type Declarations to Rust
When we declare a type like
```lisp
(decl MyEnum (enum
(A (x u32) (y u32))
(B)))
```
ISLE will generate the Rust type definition
```rust
#[derive(Clone, Debug)]
pub enum MyEnum {
A { x: u32, y: u32, },
B,
}
```
Note that enum variants with no fields take on the brace-less form,
while those with fields use the named-struct-field `A { x: ... }`
form. If all variants are field-less, then the type will additionally
derive `Copy`, `PartialEq`, and `Eq`.
If the type is declared as extern (`(decl MyEnum extern (enum ...))`)
then the same definition is assumed to exist. Primitives (`(decl u32
(primitive u32))`) are assumed to be defined already, and are required
to be `Copy`.
All imported/extern types are pulled in via `use super::*` at the top
of the generated code; thus, these types should exist in (or be
re-exported from) the parent module.
### Symbolic Constants
ISLE allows the user to refer to external constants as follows:
```lisp
(extern const $I32 Type)
```
This allows code to refer to `$I32` whenever a value of type `Type` is
needed, in either a pattern (LHS) or an expression (RHS). These
constants are pulled in via the same `use super::*` that imports all
external types.
### Exported Interface: Functions and Context Trait
The generated ISLE code provides an interface that is designed to be
agnostic to the embedding application. This means that ISLE knows
nothing about, e.g., Cranelift or compiler concepts in
general. Rather, the generated code provides function entry points
with well-defined signatures based on the terms, and imports the
extern constructors and extractors via a context trait that the
embedder must implement.
When a term `T` is defined like
```lisp
(decl T (A B C) U)
```
and has an internal constructor (provided by `rule` bodies), then a
function with the following signature will be exported from the
generated code:
```rust
pub fn constructor_T<C: Context>(ctx: &mut C, arg0: &A, arg1: &B, arg2: &C) -> Option<U>;
```
In other words, `constructor_` is prepended, and the function takes
the expected arguments, along with a "context" (more on this
below). It returns an `Option<U>` because internal constructors can be
partial: if no rule's pattern matches, then the constructor
fails. Note that if a sub-constructor fails, no backtracking occurs;
rather, the failure propagates all the way up to the entry point.
What is this "context" for? The context argument is used to allow
external extractors and constructors to access the necessary state of
the embedding application. (For example, in Cranelift, it might be the
`LowerCtx` that controls the code-lowering process.)
The context is a trait because we want to decouple the generated code
from the application as much as possible. The trait will have a method
for each defined external extractor and constructor. For example, if
we have the following terms and declarations:
```lisp
(decl A (u32 u32) T)
(extern constructor A build_a)
(decl B (T) U)
(external extractor B disassemble_b)
```
then the `Context` trait will include these methods:
```rust
trait Context {
fn build_a(&mut self, arg0: u32, arg1: u32) -> T;
fn disassemble_b(&mut self, arg0: &U) -> Option<T>;
}
```
These functions should be implemented as described above for external
constructors and extractors.
Note that some external extractors are known to always succeed, for
example if they are just fetching some information that is always
present; in this case, the generated code can be made slightly more
efficient if we tell the ISLE compiler that this is so. By declaring
```lisp
(external extractor infallible B disassemble_b)
```
we eliminate the `Option` on the return type, so the method is instead
```rust
trait Context {
// ...
fn disassemble_b(&mut self, arg0: &U) -> T;
}
```
## ISLE Internals
### Compiler Stages
Some detail and pointers to the compilation stages can be found in the
[README](../isle/README.md). The sequence starts as any ordinary
compiler: lexing, parsing, semantic analysis, and generation of an
IR. The most unique part is the "decision trie generation", which is
what converts the unordered-list-of-rule representation into something
that corresponds to the final Rust code's control flow and order of
matching operations.
We describe this data structure below with the intent to provide an
understanding of how the DSL compiler weaves rules together into Rust
control flow. While this understanding shouldn't be strictly necessary
to use the DSL, it may be helpful. (The ultimate answer to "how does
the generated code work" is, of course, found by reading the generated
code; some care has been taken to ensure it is reasonably legible for
human consumption!)
### Decision Trie
The heart of the ISLE transformation lies in how the compiler converts
a list of rules into a scheme to attempt to match rules in some order,
possibly sharing match operations between similar rules to reduce
work.
The core data structure we produce is a "decision trie" per internal
constructor body. This is an intermediate representation of sorts that
is built from individual-rule IR (LHS + RHS) sequences, and is then
used to generate Rust source.
The decision trie is, as the name implies, a kind of decision tree, in
the sense that we start at the root and move down the tree based on
the result of match operations (each feeding one "decision").
It is a "trie" (which is a kind of tree) because at each level, its
edges are labeled with match operations; a trie is a tree where one
input character from an alphabet is used to index children at each
level.
Each node in the tree is either an internal decision node, or a leaf
"expression" node (which we reach once we have a successful rule
match). The "execution semantics" of the trie are
backtracking-based. We attempt to find some path down the tree through
edges whose match ops run successfully; when we do this to reach a
leaf, we have the values generated by all of the match ops, and we can
execute the sequence of "expression instructions" in the leaf. Each
rule's left-hand side becomes a series of edges (merged into the
existing tree as we process rules) and each rule's right-hand side
becomes one leaf node with expression instructions.
At any point, if a match op does not succeed, we try the next out-edge
in sequence. If we have tried all out-edges from a decision node and
none were successful, then we backtrack one level further. Thus, we
simply perform an in-order tree traversal and find the first
successful match.
Though this sounds possibly very inefficient if some decision node has
a high fan-out, in practice it is not because the edges are often
known to be *mutually exclusive*. The canonical example of this is
when an enum-typed value is destructured into different variants by
various edges; we can use a Rust `match` statement in the generated
source and have `O(1)` (or close to it) cost for the dispatch at this
level.[^8]
Building the trie is a somewhat subtle procedure; see [this block
comment](https://github.com/bytecodealliance/wasmtime/blob/main/cranelift/isle/isle/src/trie.rs#L15-L166)
for more information regarding the trie construction algorithm.
[^8]: The worst-case complexity for a single term rewriting operation
is still the cost of evaluating each rule's left-hand side
sequentially, because in general there is no guarantee of
overlap between the patterns. Ordering of the edges out of a
decision node also affects complexity: if mutually-exclusive
match operations are not adjacent, then they cannot be merged
into a single `match` with `O(1)` dispatch. In general this
ordering problem is quite difficult. We could do better with
stronger heuristics; this is an open area for improvement in the
DSL compiler!
## Reference: ISLE Language Grammar
Baseline: allow arbitrary whitespace, and Lisp-style comments (`;` to
newline). The grammar accepted by the parser is as follows:
```bnf
<ISLE> ::= <def>*
<def> ::= "(" "type" <typedecl> ")"
| "(" "decl" <decl> ")"
| "(" "rule" <rule> ")"
| "(" "extractor" <etor> ")"
| "(" "extern" <extern> ")"
<typedecl> ::= <ident> [ "extern" ] <typevalue>
<ident> ::= ( "A".."Z" | "a".."z" | "_" | "$" )
( "A".."Z" | "a".."z" | "_" | "$" | "0".."9" | "." )*
<const-ident> ::= "$" ( "A".."Z" | "a".."z" | "_" | "$" | "0".."9" | "." )*
<int> ::= [ "-" ] ( "0".."9" )+
| [ "-" ] "0x" ( "0".."9" "A".."F" "a".."f" )+
<typevalue> ::= "(" "primitive" <ident> ")"
| "(" "enum" <enumvariant>* ")"
<enumvariant> ::= <ident>
| "(" <ident> <enumfield>* ")"
<enumfield> ::= "(" <ident> <ty> ")"
<ty> ::= <ident>
<decl> ::= <ident> "(" <ty>* ")" <ty>
<rule> ::= <pattern> <expr>
| <prio> <pattern> <expr>
<prio> ::= <int>
<etor> ::= "(" <ident> <ident>* ")" <pattern>
<pattern> ::= <int>
| <const-ident>
| "_"
| <ident>
| <ident> "@" <pattern>
| "(" "and" <pattern>* ")"
| "(" <ident> <pattern-arg>* ")"
<pattern-arg> ::= <pattern>
| "<" <expr> ;; in-argument to an extractor
<expr> ::= <int>
| <const-ident>
| "#t" ;; Scheme-like "true": shorthand for 1
| "#f" ;; Scheme-like "false": shorthand for 0
| <ident>
| "(" "let" "(" <let-binding>* ")" <expr> ")"
| "(" <ident> <expr>* ")"
<let-binding> ::= "(" <ident> <ty> <expr> ")"
<extern> ::= "constructor" <ident> <ident>
| "extractor" [ "infallible" ] <ident> <ident>
| "const" <const-ident> <ident> <ty>
```