Metaprogramming

Puck has rich metaprogramming support, heavily inspired by Nim. Many features that would have to be at the compiler level in most languages (error propagation ?, std.fmt.print, async/await) are instead implemented as macros within the standard library.

Macros take in fragments of the AST within their scope, transform them with arbitrary compile-time code, and spit back out transformed AST fragments to be injected and checked for validity. This is similar to what Nim and the Lisp family of languages do. By keeping an intentionally minimal AST, some things not possible to express in literal code may be expressible in the AST: in particular, bindings can be injected in many places they could not be injected in ordinarily. (A minimal AST also has the benefit of being quite predictable.)

Macros may not change Puck's syntax: the syntax is flexible enough. Code is syntactically checked (parsed), but not semantically checked (typechecked) before being passed to macros. This may change in the future. Macros have the same scope as other routines, that is:

function scope: takes the arguments within or following a function call

macro print(params: varargs) =
  for param in params:
    result.add(quote(stdout.write(`params`.str)))

print(1, 2, 3, 4)
print "hello", " ", "world", "!"

block scope: takes the expression following a colon as a single argument

macro my_macro(body)

my_macro:
  1
  2
  3
  4

operator scope: takes one or two parameters either as a postfix (one parameter) or an infix (two parameters) operator

macro +=(a, b) =
  quote:
    `a` = `a` + `b`

a += b

Macros typically take a list of parameters without types, but they optionally may be given a type to constrain the usage of a macro. Regardless: as macros operate at compile time, their parameters are not instances of a type, but rather an Expr expression representing a portion of the abstract syntax tree. Similarly, macros always return an Expr to be injected into the abstract syntax tree despite the usual absence of an explicit return type, but the return type may be specified to additionally typecheck the returned Expr.

As macros operate at compile time, they may not inspect the values that their parameters evaluate to. However, parameters may be marked with static[T]: in which case they will be treated like parameters in functions: as values. (note static parameters may be written as static[T] or static T.) There are many restrictions on what might be static parameters. Currently, it is constrained to literals i.e. 1, "hello", etc, though this will hopefully be expanded to any function that may be evaluated statically in the future.

macro ?[T, E](self: Result[T, E]) =
  quote:
    match self
    of Okay(x): x
    of Error(e): return Error(e)

func meow: Result[bool, ref Err] =
  let a = stdin.get()?

The quote macro is special. It takes in literal code and returns that code as the AST. Within quoted data, backticks may be used to break out in order to evaluate and inject arbitrary code: though the code must evaluate to an expression of type Expr.

The Expr type is available from std.ast, as are many helpers, and combined they provide the construction of arbitrary syntax trees (indeed, quote relies on and emits types of it). It is a union type with its variants directly corresponding to the variants of the internal AST of Puck.

Construction of macros can be difficult: and so several helpers are provided to ease debugging. The Debug and Display interfaces are implemented for abstract syntax trees: dbg will print a representation of the passed syntax tree as an object, and print will print a best-effort representation as literal code. Together with quote and optionally with static, these can be used to quickly get the representation of arbitrary code.