5 Spaces
So far, we have seen macros two different spaces: definitions and expressions, each with its own macro-definition form, defn.macro or expr.macro. Rhombus includes many other such spaces.
In general, a space is a parsing context that has its own bindings. For example, * in an expression context is the multiplication operator, but * in a regular-expression context is a Kleene star. Getting from one space to another requires a form in one space, such the rx expression form, that bridges to the other space. Here are some examples showing how a an asterisk interpretation is context-dependent.
> 2 * 3
6
RXMatch("aaa", [], {})
#false
RXMatch("111111", [], {})
In the above example, rx is an expression form that expects a ‹term› afterward using '…'. The quoted form is not treated as a syntax object template, but instead parsed as a regular expression. The result of an rx expression is an RX object that has an RX.match method.
5.1 Binding Macros
Because each space has its own set of bindings, it naturally supports its own, individualized set of macros. Even better, because a separate space is used for bindings, Rhombus’s binding positions are macro extensible.
To demonstrate how such macros work and show the power of spaces other than the definition and expression context spaces, here’s an example using bind.macro, which binds in the space used to parse Rhombus binding positions. Binding positions include the left-hand side of def, the arguments for a fun, the binding patterns of a match form, and more.
fun lookup(env ⊢ expr):
env[expr]
With expr.macro, the pattern '$a ⊢ $b' means that the left-hand and right-hand arguments of ⊢ will be parsed as expressions. With bind.macro, the pattern '$a ⊢ $b' means that the left-hand and right-hand components of ⊢ will be parsed as bindings.
This combination of features explains how class Id(name :: Symbol) makes Id work as a constructor and also as a pattern form for match. The class form binds Id as a function in the expression space and also as a pattern-matching operator in the binding space. Since Id is a binding form, it can be used as a match or def pattern. It can even be used with ⊢.
fun interp(env ⊢ Id(name)):
env[name]
5.2 Parsing in Specific Spaces
The rx form itself is parsed in the expression space, but it needs to parse the inside of subsequent '…' in the regular-expression space. Similarly, def, fun, and match start out in definition and expression spaces, but they need to parse portions of their input as bindings. As one more example of a space, we will consider annotations, which appear after :: in bindings. For example, Int is an annotation, and the :: form parses an annotation on its right-hand side to impose a constraint on it’s left-hand side:
def: value does not satisfy annotation
value: "apple"
annotation: Int
The use of :: in a syntax-pattern escape to specify a syntax class is not quite the same as using :: in a binding to specific a annotation, but they’re similar enough that Rhombus uses the same operator name in those different contexts. In other words, unquote bindings and syntax classes are two more examples of spaces.
Note that a class form like class Id(name :: Symbol) binds Id not only as a constructor for expressions and a pattern-matching form for bindings, but also as an annotation to impose a constraint that an object instantiates the class. All of these spaces work together via macros in one space that bridge to another space.
For each space, an associated syntax class provides a way to parse in that space. The annot_meta.Parsed syntax class triggers annotation parsing, for example.
expr.macro 'identity_at($(ann :: annot_meta.Parsed))':
> add0(1)
1
> add0("apple")
fun: argument does not satisfy annotation
argument: "apple"
annotation: Int
> identity_at(0)
?: literal not allowed as an annotation
This identity_at macro would work about as well if ann had no annotation and were simply spliced into the generated fun form as a group. Specifying the annot_meta.Parsed syntax class ensures that the annotation is parsed early and cannot be accidentally or unexpectedly treated as anything other than an annotation in the expansion of identity_at.
Functions like annot_meta.unpack_predicate support introspection on a parsed annotation to extract its meaning; that’s beyond the scope of this tutorial, but those kinds of facilities are used by :: to extract a run-time predicate associated with a parsed annotation or by rx to extract the meaning of a parsed regular expression.
5.3 Macro Patterns
A syntax class like annot_meta.Parsed or expr_meta.Parsed can be used only in places where the Group syntax class would be allowed. In the identity_at example above, annot_meta.Parsed works because $(ann :: annot_meta.Parsed) is alone within its ‹group›, so it can match the whole ‹group›. The definition
expr.macro 'identity_at $(ann :: annot_meta.Parsed)':
would not be allowed, because enforestation would not know where to stop trying to parse an annotation and where to start parsing a subsequent expression, which would be relevant for input such as
identity_at Int (1)
Instead, the macro pattern 'identity_at $(ann :: annot_meta.Parsed)' is statically rejected as having annot_meta.Parsed in a disallowed position.
We could resolve the ambiguity by disallowing an expression immediately after identity_at. That choice can be implemented by making the pattern unambiguously consume all remaining terms and requiring them to parse as an annotation:
expr.macro 'identity_at $term ...':
| '$(ann :: annot_meta.Parsed)':
> double_reverse([1, 2, 3])
[1, 2, 3]
> double_reverse(0)
fun: argument does not satisfy annotation
argument: 0
annotation: List.of(Int)
These complexities illustrate why expr.macro automatically treats a pattern '$a op $b' as demanding expr_meta.Parsed parsing for $a and $b. It’s complex, at best, to write that constraint directly. Furthermore, automatic expression parsing for the left-hand input to an infix macro is necessary for the infix operator to be discovered at all. Automatic parsing is not always required for right-hand side of a prefix or infix operator, and more flexibility is needed for identity_at. As another example, the . expression operator expects a field or method name on its right, not another expression.
Overall, macro-definition forms like expr.macro interpret a macro pattern to define name in the following ways:
|
|
| infix with parsed left and parsed right | |
|
|
| infix with parsed left and remainder of ‹group› matched to 'any_pat' | |
|
| '$id name tail_pat' |
| infix with parsed left and remainder of ‹group› matched to 'tail_pat', where a tail_pat is one that ends with ..., a ‹block› pattern, or an ‹alts› pattern |
|
| '$id name other_pat' |
| infix with parsed left and greedy match to other_pat |
|
|
| ||
|
| 'name $id' |
| prefix with parsed right |
|
| 'name any_pat $()' |
| prefix with remainder of ‹group› matched to 'any_pat' |
|
| 'name tail_pat' |
| prefix with remainder of ‹group› matched to 'tail_pat', where a tail_pat is one that ends with ..., a ‹block› pattern, or an ‹alts› pattern |
|
| 'name other_pat' |
| prefix with greedy match to other_pat |
5.4 Exercise
In storage.rhm, the storage macro defines an identifier as a function that accepts 0 or 1 arguments. When the function receives 0 argument, it returns the current value in storage. When it receives 1 argument, it sets the storage content.
An annotation guards all accesses and updates of the storage, including the initial value. But the storage implementation is inefficient in a way, because it copies terms to describe an annotation into three places, which means that the annotation is parsed three times.
When storage expands, it prints “expand expr”, and when the annotation NoisyInt expands to just Int, it prints “expand annot”. The way storage copies an unparsed annotation is reflected by “expand annot” printing more times than “expand expr”.
Fix the macro so that the number of “expand annot” printouts matches the number of “expand expr” printouts, instead of being three times as many.
5.5 Exercise
The interpreter in interp_macro_pattern.rhm defines a => macro to make recursive calls in interp a little prettier.
Unfortunately, the expansion of => interferes with the tail call that should be in the function returned by the Fun(arg, body) case. As a result, the Ω example at the end (commented out) consumes memory without bound, instead of running in constant space.DrRacket shows its heap size at the bottom right. Before trying Ω, make sure you have a memory limit in place as reported in DrRacket’s REPL interaction pane!
Adjust the => macro so that it recognizes when the right-hand side of => is a single identifier, and in that case, ensure that interp is in tail position within the generated expansion.