Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Meta Functions

x2c extends C. The subset that a meta function can execute during compilation cuts across both C features and x2c additions: integer loops, local pointer indirection, collection literals and method calls can work; a native struct field or an otherwise ordinary library call can stop it. There are three separate questions:

  1. Can the body execute? Its syntax and values need compile-time representations. This includes parameters, locals, mutation and returns.
  2. Can each operation execute? Every resolved call needs a compile-time binding. Having a supported receiver type does not expose its entire API.
  3. Can the answer become program code? Returning a value to another meta function, inserting it with $helper(...), and folding an ordinary call have different limits.

meta is not a purity annotation. A body can mutate locals, arrays and maps, and pass local addresses to other meta functions. Its compile-time objects belong to the evaluator; they are not the objects the eventual program allocates.

Start with an ordinary function. This one takes an integer and returns an integer, using the same braces, return, and arithmetic as C:

int poly(int n) {
  return n * n + 3 * n + 1;
}

Calling poly(7) gives 71. Now put meta before its definition:

meta int poly(int n) {
  return n * n + 3 * n + 1;
}

The body has not changed. meta makes it available to the compiler during translation as well as to the finished program. You can use it for an ordinary calculation; it does not have to inspect types or generate code.

Call it in the program

An ordinary call uses parentheses and commas, just as before. Here the argument is a local variable:

meta int poly(int n) {
  return n * n + 3 * n + 1;
}

int main(void) {
  int n = 7;
  printf("%d\n", poly(n));
  return 0;
}
71

This uses the function’s run-time form. The compiler emits C for the body, and the finished program calls it.

Ask the compiler to calculate it

Use $poly(7) to call the same function during translation:

meta int poly(int n) {
  return n * n + 3 * n + 1;
}

int main(void) {
  printf("%d\n", $poly(7));
  return 0;
}
71

The $ prefix requires the compiler to run the function and insert its result. The call uses the same parentheses and commas as an ordinary x2c call. Here it inserts the integer 71 before the program is built.

The optional Lisp equivalent is $(poly 7). It calls the same implementation; use it when working with Lisp code already in the compiler session.

These are the two forms of a meta function: one runs in your program, and one runs in the compiler. Both come from the same body. Functions that need compiler queries, explicit compile-time calls or source-template construction are exceptions: they only run during translation. We will reach those after ordinary calculations.

Compute the arguments too

An explicit call can calculate its arguments before invoking the function:

meta int twice(int n) => n * 2;

int main(void) {
  printf("%d\n", $twice(3 + 4));
  return 0;
}
14

The arguments must be resolvable during compilation. They may be computed expressions and calls to other meta functions, rather than only literals. An unresolved runtime input is an error; the explicit call cannot fall back to runtime execution. An existing macro with the same name takes precedence. Inside an executing meta body, arguments use that evaluation’s current values.

Dynamic runtime calls

A meta function can be stored in a Func and called with runtime arguments:

meta int poly(int n) => n * n + 3 * n + 1;

int main(void) {
  Func calculate = poly;
  printf("%d\n", calculate(7));
  return 0;
}
71

This dispatches the compiled implementation. It does not select the interpreted body used during compilation. An embedding program can apply an interpreted callable through the Lisp evaluator API, but converting a meta function to Func does not export that interpreted callable automatically.

Constant calls are folded

An ordinary call with literal arguments can also be calculated during translation. You do not have to prefix the name with $ to benefit from it:

meta int poly(int n) {
  return n * n + 3 * n + 1;
}

int main(void) {
  int n = 7;
  printf("constant %d\n", poly(7));
  printf("local    %d\n", poly(n));
  return 0;
}
constant 71
local    71

The generated C shows the difference:

printf("constant %d\n", 71);
printf("local    %d\n", poly(n));

Here poly(7) becomes 71, while poly(n) remains a call. This automatic substitution is called folding. It is an optimization intended to preserve the runtime answer; the capability catalog below records current differences. $poly(7) requires compile-time evaluation; poly(7) leaves folding to the compiler.

Use x2c conveniences

The body can also use familiar x2c conveniences. An expression-bodied function uses => in place of braces and return:

meta int poly(int n) => n * n + 3 * n + 1;

A String parameter gives access to string operations through dot syntax:

meta static int width(String text) => text.len() * 2;

int main(void) {
  printf("%d\n", $width("abcd"));
  return 0;
}
8

static follows meta and keeps its usual meaning for the emitted function. A body can use local variables, ordinary control flow, foreach, and String, List, Array, and Map operations with compile-time bindings. The restrictions below describe where this stops.

When meta is a keyword

meta is contextual. It marks a function only when a function definition follows it. Everywhere else it is an ordinary identifier:

List meta = %(a b);

struct MetaHolder { int meta; };

int main(void) {
  meta = %(a b c);
  struct MetaHolder holder = { .meta = 3 };
  printf("%d %d\n", meta.len(), holder.meta);
  return 0;
}
3 3

A meta declaration needs a body. A prototype is an error, because the compiler has nothing to run.

When folding applies

Where both forms agree, the compiler may answer a call from the compile-time form and put the answer in the call’s place. A call is answered this way only when all of the following hold.

  • The callee is a meta function this unit defines. An imported one keeps the run-time call to the unit that emits it.
  • Every argument has a value available to the constant reader: a literal, literal template, previously cached constant, or supported conversion of one. This optional path does not evaluate every resolvable expression; use an explicit dollar call when compile-time execution is required.
  • Every argument already has its parameter’s declared type.
  • The result can be represented as typed program code: a scalar, String, Symbol, immutable List of representable elements, or boxed value.

This is an optimization, so you do not need to arrange for it. Its equivalence depends on staying within the supported operation semantics; see the missing-value differences below. Mutable Array and Map results, callable values and arbitrary native addresses keep their calls. Immutable Lists are rebuilt through ordinary cons expressions; String and boxed results use ordinary expression conversion and ownership. This restriction on folding does not restrict internal meta return types.

The compile-time subset

The following tables separate supported values, available operations and current refusals. They catalog the implemented surface and known differences; untested combinations are identified explicitly rather than implied to work.

Values, declarations and mutation

The table describes current support, not a promise that every C operation on a listed type works. The operation inventory below further limits calls.

Value or constructConstruction, parameters and returnsReads and changes during compilation
Native integers, including char, narrow, unsigned and wide typesNumeric literals and typed locals; values can pass between meta functions and return from them. Width alone is not a prohibition.Arithmetic, comparisons, casts, assignment and local compound updates. Source numeric literals use the shared scanner and type conversion rules, including integer base prefixes and U, L and LL suffixes.
float, doubleLiterals, locals, parameters and returns work, including f/F floating suffixes.Arithmetic and scalar conversions work. Floating results can also be inserted as typed expressions.
long doubleTyped parameters, returns and L-suffixed floating literals work.Decimal and hexadecimal floating literals use the declared numeric precision. The focused checks do not establish parity for every extended-precision calculation.
void returnA meta helper may return true no-value, including a helper writing through an out-parameter. Bare return and fallthrough preserve that result.Raw evaluator slots, Var parameters and Var results transport void without changing it to an empty List. Concrete typed value parameters and ordinary collections still reject it.
String, SymbolString/symbol literals, computed strings and interpolation; parameters and returns.String indexing reads character codes. Bound string methods work; indexed string writes do not. Symbols have the small method surface listed below.
ListTemplates, braced/list-compatible initialization, conversion from Array; parameters and returns.Index/association reads, traversal, matching and bound methods. No indexed assignment; build a new List instead. A returned data List is not automatically an expression.
Array[], [a, b], Array.new(), conversion from List; parameters and returns.Indexed reads/writes and the bound mutating methods. Contents can mix represented values and nest collections.
Map{}, keyed literals, Map.new(); parameters and returns.Keyed reads/writes and bound methods; represented collections and callable values can be stored inside it.
VarBoxes represented numbers, strings, symbols, collections and callable values.Only the exposed operations below. Compile-time void and an empty List remain distinct. Lisp conditions treat both as false; x2c runtime Var.truth still raises on void.
FuncLambdas with typed or bare parameters, captures and references to available functions; parameters and returns between meta functions.Dynamic calls and storage in collections work. This does not expose arbitrary native function-pointer calls or unrepresented argument types.
C-style array declarationsA literal-sized one-dimensional array, such as int a[3] = {1, 2};, has compile-time storage. Omitted elements are filled with zero-like values.Indexing and simple assignment work; passing the array to an indexed pointer parameter works in the tested case. At compile time, a mutable evaluator cell holds a dynamic Array of Var values. Runtime uses native C array storage; the evaluator does not use alloca. See element/dimension limits below.
Pointers to localsint *p = &n;, copying that pointer and passing it to another meta function work. The evaluator exposes the source pointer tag while retaining its own cell.*p reads and *p = value writes the local. General pointer arithmetic, address-of an array element and native memory/layout access are not supplied by this cell representation.
Native structs and unionsNative aggregate locals and native field access are rejected; a type name in a signature alone does not establish a usable value representation.Dot methods resolve as calls and can work. Dot fields need native aggregate representation and do not. Compiler queries may inspect a struct’s type and build future field access code without reading a struct value.
File, buffers and other resource typesNo general compile-time constructor/operation surface is installed for these types. A declaration or opaque type name alone does not make the resource usable.For example, File.open has no binding. Use the compiler’s explicit text-embedding operation for source-dependent text.

Collections hold values, not arbitrary native memory. Nested collections keep references to their contained objects; mutating a shared Array or Map changes that object. Native aggregates do not become legal collection contents merely because the container itself is supported.

Native array coverage is narrower than C’s array model. Integer arrays have fixture coverage; focused probes also cover double and String elements. A dimension such as 1 + 1 is rejected even though C can compute it, and a nested braced initializer for int a[2][2] is rejected. The implementation stores a C-style array in a dynamic container, using its first declared dimension; this is not general multidimensional or native-layout support. Element initialization applies the declared scalar conversion. For example, unsigned char a[1] = { n }; return a[0]; with 257 returns 1 both during compilation and at runtime. Signed-byte, float-rounding and floating-to-integer initialization also have differential checks. Other element types, array decay/alias combinations and every zero-initialization case remain coverage gaps.

A null-pointer constant such as (char *) 0 can supply the default character set for String.strip. The preprocessor name NULL is not resolved by the compile-time expression reader; using that name alone still reaches the file-scope-state restriction. This does not imply a native pointer dereference or arbitrary address support.

These two functions demonstrate a supported address and a supported chain:

meta static void meta_set(int *out) { *out = 9; }

meta static int meta_address(void) {
  int n = 1;
  meta_set(&n);
  return n;
}

meta static int meta_parts(String path) => path.split(".").len();

int main(void) {
  printf("%d %d\n", $meta_address(), $meta_parts("a.b.c"));
  return 0;
}
9 3

The chain calls String.split, then List.len. Neither dot reads a native struct field.

Which library operations are available

A method uses its ordinary resolved call, so text.len() and String.len(text) reach the same operation. Chaining adds no separate restriction: every call in the chain must be available.

The complete API coverage report lists every exported operation on String, List, Array, Map, Symbol and Var, including generated methods and advanced internals. It records signatures, binding locations, verified examples, reproduced failures and untested operations separately. The optional inventory tool reads every standard compiler session layer; searching only one binding file misses operations such as List.car, List.cdr and Var.cons.

A recent batch added 19 bindings; it did not complete the API surface. The ordinary List and Var selectors caar, cadr, cddr and caddr reuse those same operations. String, List, Array and Map expose their str and repr rendering; Symbol also exposes repr and compare, and Var.kind reports a value’s kind. These calls use the existing value implementations rather than a separate formatting or comparison algorithm.

Iterator producers and functional collection operations are also available in x2c-style meta functions. Omit the native destination argument when an iterator is consumed by the same expression. The compile-time binding allocates the iterator in the session Scope; it still uses the native lazy producer and borrows its source and any callback.

meta int key_count(Map values) => values.keys().count();

meta List shifted(int amount) {
  List result = range(1, 3, 1)
    .map(%!(Var value) => value + amount);
  return result;
}

List, Array and String mapping and filtering use the same native operations with an adapter for the interpreted callback. Lazy Iter mapping, filtering, pairing and collection work the same way. Calls made as Lisp treat nil and void as false; callbacks in x2c-style meta functions retain ordinary Var truth.

A binding name alone is not proof of runtime-equivalent behavior. A missing binding also does not explain why it was omitted. Some operations need only an adapter over existing values; others need native pointer arguments or resource ownership. Explicit caller-supplied struct Iter storage remains a runtime contract and has no compile-time representation. Use the destination-free form inside a meta function. List, Array and Iter folds preserve a true void no-seed argument. Empty or exhausted Iter.next, find, min and max calls return true runtime void, distinct from Lisp nil.

Other installed meta functions and the compiler operations declared in meta.x extend this surface; including a normal function declaration does not install its body for compile-time execution.

Missing List/Array/Map lookups and removals preserve runtime void; they no longer become an empty List in the evaluator. This covers List getindex, last, assoc, get, caar, cadr and caddr; Array getindex, setindex, take_last, shift, remove and insert; and Map get, getindex and del. caar stops when either selection is absent. Ordinary Lists, Arrays and Maps still reject void as an element, key or value, so a rest call cannot pack it into its argument List. Lisp conditions treat void as false while the normal x2c Var.truth contract still raises <void-op>; compare explicitly with void when both forms must agree. The compiler loads several binding layers, including etc/init.xlisp, etc/lisp-values.xlisp and etc/comptime.xlisp; supported body forms live in src/comptime.x.

Control flow and current rejections

if, conditional expressions, short-circuit boolean operations, while, for, do, break, continue, recursion, foreach, match, templates, lambdas and destructuring have compile-time support. A switch may share labels and its final arm may end normally; an earlier arm must not execute through to the next one. Recursive calculations remain subject to the compiler’s evaluation limits.

Some combinations still decline: switch with a subject needing a temporary binding on a loop path; destructuring a source needing such a binding on a loop path; and taking the address of a destructured local. Match patterns must fold at lowering time, so a pattern interpolating a local value is not generally supported. These are binding/lowering gaps, not fundamental restrictions on loops, destructuring or pattern matching.

Mutation is position-sensitive. n++ as a statement is supported, but return n++; is not. a[i] = a[i] + 1; and *p = *p + 1; are supported shapes; a[i] += 1; and (*p)++; are not. An assignment used as an expression is not a general substitute for a statement.

The table separates present restrictions from design assessment. “Gap” means feasible in principle, not scheduled or promised support.

Currently unsupportedWhat would be needed; fundamental or gap?
goto, switch fallthroughGap: control-flow lowering that preserves the transfer.
Postfix expression values, compound updates to indexed/dereferenced placesGap: preserve the old result and evaluate the destination once.
Computed native-array dimensions, general multidimensional arrays and missing element conversionsGap: extend the represented array shape and typed operations.
Native struct values/fieldsGap: a representation preserving value copying, identity where applicable, and field semantics. A Map alone would not preserve C value semantics.
Unions, native layout and general pointersRepresentation gap: model storage, aliasing and layout. A pointer to an actual future runtime object cannot be dereferenced during compilation; that is a phase boundary. Symbolic addresses would be different from accessing that object now.
deferGap for deferred execution in general. Explicitly freeing evaluator-owned objects conflicts with the current ownership model; it is not an argument that all deferred actions are impossible.
try, catch, finally, raise in a meta bodyGap: exception transfer and cleanup need compile-time modeling. Evaluation failures can still become compiler diagnostics.
Missing library/resource operations, including File.openAPI gap: implement bindings and appropriate resource lifetimes. Compile-time file I/O is possible in principle; it is not prohibited by the phase boundary.
Loop-path temporary bindings, address-taken destructured locals, nonfolded match patternsGap: preserve required bindings and support pattern evaluation at the appropriate time.
Native sizeof expressionsGap: provide the compile-time value of the queried layout; sizeof(int) currently declines as an unsupported expression.
Named enum valuesGap: make the enumerator’s numeric value available to the lowerer.
Reading future runtime mutable stateFundamental phase boundary: that program state does not exist yet. Separate compile-time state is possible but is not the same state.
Mutable container and callable result insertionGap: preserve ownership, mutability and identity when constructing a runtime value. See the next section.

For example, these are intentionally rejected definitions:

meta int meta_field(void) {
  struct { int x; } value = { 1 };
  return value.x;
}
meta int meta_update(void) {
  int values[2] = { 1, 2 };
  values[0] += 1;
  return values[0];
}

Their diagnostic reasons are respectively a struct or union, which has no compile-time representation and update of a computed place. The compiler checks the body when installing the meta definition, so an unused function or an untaken branch does not hide an unsupported construct.

Lifetime and state

Compile-time allocations belong to the evaluator session. Do not free, close, or otherwise take over evaluator-owned objects: the ordinary resource cleanup API is not available, and defer is rejected. A returned collection can be consumed by another meta function while the session is alive; it does not become a pointer to the eventual program’s heap. A local address is useful within the calculation, not a portable constant address to embed in C.

The emitted runtime function still follows the runtime’s ownership rules. Evaluator cleanup does not add cleanup to that function. In particular, allocating scratch collections in a dual-form body does not by itself prove that repeated runtime calls have the desired lifetime behavior.

Ordinary dual-form meta functions cannot reach file-scope variables, directly or through another function. Pass values as arguments or use locals. There is an implementation exception for compiler-only functions that reach compiler operations: their state uses a separate per-unit compile-time table, without running the program’s file-scope initializers. This does not grant access to future runtime state or establish a general shared-global contract.

Results: compute, insert, or fold

A meta function can pass and return numeric, collection and callable values inside the evaluator. Inserting a result into the program adds a separate requirement: the compiler must construct code representing that value.

ResultExplicit $helper(...) insertionAutomatic ordinary-call folding
Native integers and floating valuesPreserves the numeric Var family, including width, signedness and floating precision.Uses the declared result type.
Computed stringInserts a quoted C string literal.Constructs a String expression when the declared result is String.
SymbolInserts a Symbol literal.Constructs a Symbol expression.
Identifier or nonempty code ListBinds the returned code through normal compiler binding and typing. A data List is not automatically an expression.A declared List result is reconstructed as data through ordinary cons expressions if every element is representable.
Boxed VarInsertion follows the contained value.Converts a representable contained value to Var.
Array or Map with immutable representable descendantsConstructs a fresh mutable root through the ordinary literal constructors.Keeps the call.
Nested mutable collections, Func or arbitrary native addressNo direct materialization of the evaluator object.Keeps the call.

An inserted Array or Map is a snapshot of the compile-time result. Each runtime execution of that expression allocates a fresh root in the current Scope, just like [] or {}; assigning it to another variable still aliases that root. Array order and element types are preserved. Map keys use their ordinary runtime key semantics, but reconstruction does not promise the same traversal order. A boxed Var may contain the resulting root.

Elements, keys and values may contain scalars, Strings, Symbols, or immutable Lists of those values. Nested mutable objects, including mutable objects hidden inside Lists, remain unsupported. That restriction prevents silently copying shared objects or cyclic graphs. Ordinary calls returning mutable containers remain runtime calls; explicit insertion does not authorize that optimization.

The same functions can be called during compilation or with ordinary C-style calls at runtime:

meta static double meta_half(double n) => n / 2.0;
meta static int meta_whole(void) => (int) meta_half(9.0);
meta static String meta_label(String stem) => stem.upper() + "!";

int main(void) {
  printf("%.1f %d %s\n", $meta_half(9.0), $meta_whole(),
    $meta_label("ready"));
  printf("%d %s\n", meta_whole(), meta_label("ready").str());
  return 0;
}
4.5 4 READY!
4 READY!

Scalar conversion is shared by explicit evaluation and eligible folding. Floating constants retain their binary value using hexadecimal literals; nonfinite values use the native compiler’s infinity/NaN expressions. The remaining container work must account for identity, shared references, mutation and ownership. A pointer into the evaluator is never a valid address to embed in the future program.

Compile-time arithmetic and evidence

Native scalar conversions are applied at typed declarations, assignments, casts, arguments between meta functions and returns. Array/List and Symbol/String conversions also have explicit support. This does not make all casts meaningful: reinterpreting a native address still needs a native storage representation.

The meta-differential compiler fixture checks selected narrow/unsigned arithmetic, wide intermediate values, floating operations and conversions against runtime calls. The comptime-lowering, meta-import and meta-cursors fixtures cover collections, callable values, local addresses, method chaining and iteration. They do not prove every operation/type combination equivalent. Focused probes for this chapter additionally covered nested Array/Map contents, pointer out-parameters, double/String native array elements, wide and floating internal returns, and the rejected shapes shown above. The meta-numeric-lowering fixture checks numeric suffixes and array element conversions against runtime results. Other array conversions, extended-precision parity, general pointer behavior, all callback signatures and resource lifetimes remain unverified. An _Alignof(int) probe failed during ordinary parsing, before meta lowering; that does not establish a meta-specific restriction. This catalog covers the installed operation surface and identified lowering boundaries, not an exhaustive proof of every combination of C and x2c syntax.

From values to code

So far we have passed values in and calculated values out. A macro works with the code being compiled. A meta function can take that code as a List, inspect it, and return new code for the compiler to insert.

For this part, read Compile-time Macros first. The macro declares what code to capture; the meta function implements the transformation in x2c. Include meta.x to use the compiler’s x2c_* operations.

Calling a meta function from a macro body

A macro body can call a meta helper with $helper(args). Passing a captured hole directly supplies its code to the helper; it does not evaluate the future runtime expression. This form works for code transformations such as the field query below. Complete captures also retain their source information for text and location queries; constructed subtrees do not acquire it.

#include "x2c.x"
#include "meta.x"

typedef struct Point { int x, y, z; } Point;

meta static List field_count(List receiver) =>
  x2c_literal_int(x2c_type_fields(x2c_syntax_type(receiver)).len());

macro Expression $probe.count(Expr $value) => ($field_count($value))

int main(void) {
  Point p = { 1, 2, 3 };
  printf("count %d\n", $probe.count(p));
  return 0;
}
count 3

The macro body is one call and nothing else. That is the usual shape: the macro declares the holes and the result kind, and the meta function does the work.

What the function returns decides what the expansion is. A List one of the compiler operations built represents code. x2c_literal_int, x2c_literal_string and x2c_literal_symbol each return an expression holding a value.

Source templates from meta functions

A Unit or Statement macro used as an expression inside a meta function constructs a deferred template invocation. Its arguments are values computed by the meta function. The returned code expands when inserted into the program, using ordinary macro substitution, scope and binding rules. Expression macros retain their usual expansion behavior.

macro Unit $make_function(Name $name, Expr $result) => {
  int $name(void) { return $result; }
}

meta static List build_function(String name, int n) {
  List node = $make_function(name, n * 2);
  return %($node);
}

macro Unit $make_answer() => { $build_function("answer", 21)... }

$make_answer();
int main(void) {
  printf("%d\n", answer());
  return 0;
}
42

The constructor returns one code node. The helper wraps it in a List because ... inserts a sequence of nodes. It does not return an already expanded function declaration for the helper to inspect. Name, Type, parameter and statement captures still follow the source macro’s declared hole kinds.

The reverse gradient generator in lib/autodiff.xmacro uses this pattern. Its ad_reverse_function returns an invocation of the ad.gradient source macro. The macro contains the generated function declaration, tape storage, reverse-pass label and cleanup. Meta helpers compute the parameter and statement sequences. The macro owns the fresh scratch names and passes those same names to the fragment helpers, keeping all generated references in the intended function scope.

What the compiler answers

lib/meta.x declares the compiler operations a meta function can call. Each is a plain function whose name is the compile-time Lisp name with _ for ., so x2c.type.fields is x2c_type_fields from x2c. They are grouped here by the task, not by signature; the module reference lists every declaration, and the language reference gives their semantics.

The code builders are meta bodies in lib/meta.x. The Lisp functions call those same implementations. The three literal-building functions also check their Lisp arguments. Builders that only assemble Lists can also run in a linked program; x2c_expr_field and x2c_expr_cast still need compiler queries and therefore remain compile-time only. The same is true of x2c_decl_make, x2c_param_make and x2c_type_members; x2c_stmnt_make, x2c_stmnt_return and x2c_block_make only assemble code.

Building identifiers, literals and expressions. x2c_ident checks a name and returns identifier code. x2c_literal_int, x2c_literal_string and x2c_literal_symbol return an expression holding a value. x2c_expr_ident, x2c_expr_field, x2c_expr_index, x2c_expr_call, x2c_expr_composite and x2c_expr_cast assemble the six expression shapes a generator needs. Compose them rather than writing node shapes by hand, so the compiler binds and types the result:

#include "x2c.x"
#include "meta.x"

meta static List call_of(String callee, List argument) =>
  x2c_expr_call(x2c_expr_ident(x2c_ident(callee)), %($argument));

macro Expression $probe.twice(Expr $value) => ($call_of("twice", $value))

static int twice(int n) => n * 2;

int main(void) {
  int n = 21;
  printf("twice %d\n", $probe.twice(n));
  return 0;
}
twice 42

Asking about types and fields. x2c_syntax_type answers the canonical Type of an expression or binding. x2c_type_fields answers the named fields of a struct or union Type, each as a metadata row whose first element is the field name. x2c_type_layout, x2c_type_parts, x2c_type_resolve and x2c_type_value answer the remaining generated-code questions. x2c_method_resolve answers which operation a member call selects. These answers live in the compiler’s symbol table, so a macro body cannot derive them from the code it captured.

Source text and location. x2c_source_text returns the text the developer wrote for a captured hole. x2c_binding_spelling returns the name a binding was declared with. x2c_invocation_file, x2c_invocation_line and x2c_invocation_column give the site of the macro invocation. x2c_embed_text reads a file beside the source and records it as a translation dependency.

Failing with a diagnostic. x2c_diagnostic_fail reports a message at the invocation and stops the expansion. It does not return. Use it when the argument is wrong in a way the macro can see:

#include "x2c.x"
#include "meta.x"

meta static List one_word(Var node) {
  String text = x2c_source_text(node);
  if (text.contains(" "))
    x2c_diagnostic_fail("this argument must be one word", %());
  return x2c_literal_string(text);
}

macro Expression $probe.word(Expr $value) => ($one_word($value))

int main(void) {
  int seconds = 90;
  printf("word %s\n", $probe.word(seconds));
  return 0;
}
word seconds

The helper receives the complete capture, including the source information required by x2c_source_text. A computed subtree is code data, not a new source capture.

Writing $probe.word(seconds * 2) instead reports the message at that invocation:

sample.x:15:23: macro: this argument must be one word
  printf("word %s\n", $probe.word(seconds * 2));
                      ^

Two signatures differ from the corresponding Lisp functions. x2c_expr_call takes its arguments as one List, and x2c_type_value returns int.

Functions that need the compiler

Compiler queries exist only inside a compiler. A meta function that calls one, directly or through another meta function, therefore has no valid runtime form, and the compiler emits no definition for it. A body containing an explicit dollar-prefixed meta call or a source-template constructor is also compile-time-only, as are its meta callers. A call to one from a runtime body is diagnosed where it is written. Calling field_count from an earlier section at run time gives:

sample.x:19:22: macro: 'field_count' can only be called at compile time
    int n = field_count(point);
                       ^
  note: reason: it reaches a compiler operation, so no unit emits a
  definition for it; call it from a macro or another meta function

In the complete shape example below, the generated C mentions none of shape_fields, shape_names or shape_reads.

A meta function that needs no compiler query, like poly above, keeps both forms and is emitted normally.

A complete example

Two files. The first is a .xmacro holding the meta functions and the macros that call them. shape_fields asks the compiler what a struct holds. shape_names and shape_reads turn that answer into code.

/* The named fields of a struct-typed expression, in declaration order. */
meta static List shape_fields(List receiver) =>
  x2c_type_fields(x2c_syntax_type(receiver));

/* One `String` literal holding those field names, comma separated. */
meta static List shape_names(List receiver) {
  Array names = [];
  foreach (List field, shape_fields(receiver)) names.push(field.car());
  return x2c_literal_string(String.join(", ", names));
}

/* `{ p.x, p.y, p.z }`, built from the fields rather than written out. */
meta static List shape_reads(List receiver) {
  Array reads = [];
  foreach (List field, shape_fields(receiver))
    reads.push(x2c_expr_field(receiver, field.car()));
  return x2c_expr_composite(reads);
}

macro Expression $shape.names(Expr $value) => ($shape_names($value))

macro Expression $shape.reads(Expr $value) => ($shape_reads($value))

The second file imports it and uses the macros. Imports still use the compiler’s $(import "...") form; this is a loading operation, not a meta function call.

#include "x2c.x"
#include "meta.x"

$(import "shape.xmacro")

typedef struct Point { int x, y, z; } Point;

int main(void) {
  Point p = { 2, 3, 4 };
  int reads[3] = $shape.reads(p);
  printf("names %s\n", $shape.names(p));
  printf("reads %d %d %d\n", reads[0], reads[1], reads[2]);
  return 0;
}
names x, y, z
reads 2 3 4

The field names never appear in the program. The compiler supplied them, and the meta functions built "x, y, z" and { p.x, p.y, p.z } from them.

Sharing a meta function between units

A meta function written in a .x file belongs to that unit. Including that file elsewhere shares its declaration, the way including any .x file does, and the declaration alone has no compile-time form: another unit can call it at run time, and cannot call it during translation.

To share one, put it in a .xmacro that each unit imports. A .xmacro file may hold meta functions beside the macros that call them, and importing it installs their compile-time forms in the importing unit. The unit that imports the file includes meta.x, because a .xmacro borrows the consuming unit’s symbol table:

#include "x2c.x"
#include "meta.x"

$(import "shape.xmacro")

Importing the same file twice contributes one copy of each definition. A .xmacro may import another .xmacro, and a meta function two levels down reaches the consuming unit the same way.

The run-time forms are separate from this. A unit emits a definition only for the meta functions it calls at run time. The storage class says what it emits: static gives that unit its own copy, and a public name is the one copy the program links, exported by the reaching unit’s header.

Lisp interoperability

Use the x2c calls above for ordinary meta work. The following notes apply when importing or calling Lisp helpers, or working with the compiler session.

The compiler loads Lisp support into an embedded evaluator. Some loaded files are generated output: etc/builtin-macros.xlisp comes from etc/builtin-macros.x and etc/builtin-core.xlisp through tools/gen-lisp-init.py. Seeing Lisp in that generated file does not mean the algorithms still need a separate handwritten Lisp implementation. The support layer and the generated functions have different source owners.

Names the compile-time library already defines

A unit’s compile-time session inherits the compiler’s own Lisp library and cannot replace one of its definitions. A macro file or a $(...) form that defines an inherited name reports:

sample.x:2:1: macro: compile-time Lisp evaluation failed
  $(defun filter (a b) 42)
  ^^
  note: form: (defun filter (a b) 42) error: (bad-state (operation "def")
  (why "inherited") (name filter))

The library holds many ordinary words, so the name to avoid is often one you would reach for first: filter, last, map, search, len and apply are all defined. Give your own definition a different name, or a prefix of your own.

eval reads globals only

A lambda’s body reads its captures and then the session’s globals. eval is an ordinary procedure, so the form it is given is evaluated in the globals alone and does not see the bindings around the call:

(let ((z 7)) (eval (quote (add z 1))))   error: (unbound (name z))

Pass the value instead of the name: (eval (list (quote add) z 1)).

A let binding is not visible inside its own initializer, so a helper that calls itself by name must be a defun:

(let ((h (lambda (n) (h n)))) (h 3))   error: (unbound (name h))
(defun h (n) (h n))                    reads its own name