The code I never had to write

Metaprogramming is the practice of writing programs that inspect, generate, or transform other programs. In Nim, that usually means taking a small piece of source code and turning it at compile time into ordinary, statically typed Nim.

That transformation is what has kept me interested in metaprogramming over the years. I enjoy finding a small description that captures what the programmer means, then letting the compiler expand it into the larger amount of mechanical code the program needs. The generated code is not necessarily clever. Often it is exactly the tedious code I would have written by hand, and am pleased never to write again.

Three of my libraries show how my use of this idea has developed: protocoled, an early macro-based interface experiment; smartcli, which generates a typed command-line parser from help text; and dokime, which validates SQL against SQLite and generates typed query code.

protocoled: generating an interface

I wrote protocoled in the Nim 2 era, when macros were the natural tool for this work. A Nim macro receives syntax trees as NimNode values and returns a new tree for the compiler to insert in their place.

Protocoled uses a macro to turn this compact declaration:

protocol PExpr:
  proc eval(e): int

  impl PLiteral:
    var x: int
    proc eval(e): int = e.x
    proc newLit(x: int): PLiteral =
      result = PLiteral(x: x)

into code roughly like this:

type
  PExpr = ref object of RootObj
    evalImpl: proc(e: PExpr): int {.nimcall.}

  PLiteral = ref object of PExpr
    x: int

proc eval(e: PExpr): int =
  assert e.evalImpl != nil
  e.evalImpl(e)

proc evalPLiteral(e: PExpr): int =
  let e = PLiteral(e)
  result = e.x

proc newLit(x: int): PLiteral =
  result = PLiteral(x: x)
  result.evalImpl = evalPLiteral

None of this code is individually difficult, which is precisely why I do not want to maintain it. The short form records the interesting fact (that PLiteral implements eval) and the macro handles the ceremony.

This early design has an obvious constraint: implementations must live inside the protocol block and follow its construction convention. The types also belong to a single inheritance-based protocol hierarchy. It is a closed little DSL, but the result still amazes me: with one macro, we effectively gave interfaces to a language that did not have them.

From macros to compiler plugins

Nimony takes a different approach. Its primary metaprogramming mechanism is the compiler plugin: a separate Nim program compiled to a native executable. The compiler writes the relevant program fragment in an intermediate format, the plugin transforms it, and the compiler inserts the result back into the program.

I find this execution model especially useful for substantial generators. A plugin has a defined compiler representation as its boundary, is compiled and cached independently, and can use normal libraries and C interfaces. It can emit hygienic names and references, attach diagnostics to the original source, and return code for semantic checking.

smartcli: expanding help text into a parser

I've always found command-line parsing libraries cumbersome. A small CLI can require a builder API, option declarations, parser configuration, result types, validation callbacks.

What I love about smartcli is that the help text already contains almost everything. The commands, arguments, flags, choices, and descriptions are all there. Instead of describing the same interface through a library API, smartcli turns the help text itself into the program:

let options = cliapp"""Greeter v0.1

Commands:
  greet INPUT  Greet from a file
  version      Display the version

Arguments:
  INPUT  Input file

Options:
  --mode=fast|slow  Output mode
  -v, --verbose     Enable verbose output
  -h, --help        Show help"""

That one expression expands into something resembling:

type
  CliCommand = enum
    cmdNone, cmdGreet, cmdVersion

  CliMode = enum
    cliModeNone, cliModeFast, cliModeSlow

  CliOptions = object
    input: string
    command: CliCommand
    mode: CliMode
    verbose: bool

proc parseCli(): CliOptions =
  var parser = initOptParser()
  var argumentSlot = 0

  while true:
    next(parser)
    case parser.kind
    of cmdArgument:
      case argumentSlot
      of 0:
        case parser.key
        of "greet": result.command = cmdGreet
        of "version": result.command = cmdVersion
        else: cliUnexpectedArgument(spec, parser.key)
      of 1:
        result.input = parser.key
      else:
        cliUnexpectedArgument(spec, parser.key)
      inc argumentSlot
    of cmdLongOption, cmdShortOption:
      # Generated cases for help, mode, and verbose.
      discard
    of cmdEnd:
      break

  # Generated checks for missing arguments and version handling.

let options = parseCli()

The real expansion also handles short and long options, validates enum choices, and generates the command-specific argument and error paths. As the help text grows, so does the parser; the source at the call site does not become a second, handwritten parser.

I particularly like that the generated result is not a stringly typed map. options.verbose is a bool, options.mode is an enum, and options.command is another enum. The plugin performs the repetitive translation once during compilation, leaving direct branches and normal types in the executable.

dokime: expanding SQL into typed FFI code

Dokime applies the same idea across a language boundary. A query starts as an ordinary SQL string:

let user = query(db, "SELECT id, name FROM users WHERE id = ?", userId)

echo user.name

During compilation, the plugin prepares that SQL against a development database through SQLite's C API. SQLite resolves the table and columns; dokime reads the parameter count, column names, declared types, and nullability. From that metadata it can generate code along these lines:

block:
  var stmt = prepareStmt(db, "SELECT id, name FROM users WHERE id = ?", 39)
  bindParam(stmt, 1, userId)

  var row: tuple[id: int64, name: string]
  let stepCode = stepStmtCode(stmt)
  if stepReturnedRow(stepCode):
    row = (
      d: columnInt64(stmt, 0),
      name: columnString(stmt, 1)
    )

  let finalizeCode = finalizeStmtCode(stmt)
  checkStepCode(stepCode)
  checkFinalizeCode(finalizeCode)
  requireStepRow(stepCode)
  row

The small query call generates the repetitive prepare-bind-step-decode lifecycle and, more importantly, a result type that matches the selected columns. Nullability is preserved too: a nullable TEXT column becomes Opt[string], while the same metadata can shape an optional row or a stream of rows.

This is the project where compiler plugins became most exciting to me. The plugin is native code, so it can ask SQLite itself what the statement means rather than trying to reproduce SQL validation in Nim. A misspelled table or column becomes a compile error. The metadata returned by SQLite then shapes the Nim program, so user.name is checked like any other field access.

Dokime also generates the unpleasant parts of dynamic optional clauses. Given:

for user in rows(db, """
SELECT id, name FROM users WHERE 1 = 1
  [AND age >= ?]
  [AND name = ?]
""", minAge, name):
  echo user.name

the plugin validates all four possible queries at compile time. The convenience of dynamic filtering does not leave an unchecked query path.

What I would like to explore next

Lately I have been inspired by the Ada/SPARK ecosystem and its vision of high-assurance software built from specifications, contracts, and formal verification. I find the idea of moving from a high-level design toward an implementation whose important properties can be checked, not just tested, exciting.

At some point, I would like to explore what metaprogramming could contribute to a framework that helps bridge that gap, including for embedded systems and microcontroller targets. This is an aspiration rather than a roadmap, but it is the direction that currently makes me most curious about what might come next.

Comments

Popular Posts