aboutsummaryrefslogtreecommitdiff

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, ?, !, -> type sugar, => closure sugar, 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 the Lisp family of languages do. It has a number of benefits: there is no separate metaprogramming language, it is syntactically and semantically hygienic, and the underlying framework can be reused for all kinds of compile-time code execution.

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.)

Scope

Macros may not change Puck's syntax: the syntax is flexible enough. They have the same scope as other routines, that is:

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

macro print(params: varargs) =
  var res = Call("write", [stdout])
  for param in params do
    res.params.add(param)

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 an infix (two parameters) or a postfix (one parameter) operator

# operators are restricted to punctuation
macro +=(a, b) =
  Call("=", [a, Call("+", [a, b])])

a += b

Usage

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 const: in which case they will be treated like parameters in functions: as values. (note constant parameters may be written as const[T] or const T.)

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

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

Quoting

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. Thus, quoting is structured: one cannot simply quote any arbitrary section. Quoting is very powerful: most macros are implemented using it.


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 const, these can be used to quickly get the representation of arbitrary code.