An exploratory project to build Fennel macros that compile to terra's Lua language extensions, so I can play around with interactively building low-level compilers.
Find a file
2023-12-08 14:00:43 -05:00
boot.t Upgrade to Fennel 1.4.0 2023-12-08 14:00:43 -05:00
fennel.lua Upgrade to Fennel 1.4.0 2023-12-08 14:00:43 -05:00
go.fnl flesh out quoting 2023-12-04 22:47:59 -05:00
README.md tuple destructuring 2023-12-06 22:47:47 -05:00
terra.fnl tuple destructuring 2023-12-06 22:47:47 -05:00

Garden

An experiment in combining Fennel and Terra.

Rationale

Idk, seemed cool

Goals and Non-Goals

At its core are a few a largely non-opinionated macros that directly expose the functionality that is only available through Terra's custom syntax. Terra code is not necessarily meant to read like Fennel or have the same semantics. Early returns are fine, all variables are mutable, break and continue and imperative loops are the norm.

Ideally it would be possible to build a much cleaner set of macros on top of these base macros. But the goal isn't to design a nice low-level language, it's to expose a powerful compilation target. It's assumed that if you want to take advantage of Terra, it's probably because you want to do horrible code-generation tricks. Hosting it inside Fennel simply makes it more comfortable to do so.

Usage

At the top of your file, include the following:

(import-macros {: def : q : ttype : static} :terra)

The def macro defines a new terra function. The q macro defines a quoted terra expression. The ttype macro allows you to specify terra type definitions that can't be expressed with regular lua syntax. The static macro allows you to define "global" terra variables.

Notably, all of these macros return values, and none of them define new variables, local or global. I could maybe be persuaded to make def work like fn and optionally define a local, but for now, eh, whatever.

def

Defines a function, compiling down to the terra keyword. Can also be used to create an undefined function, if called with no statements.

; syntax:
(def [argname1 argtype1 argname2 argtype2... : rettype1 rettype2...] statement...)
(def [argtype1 argtype2... : rettype1 rettype2...])

; examples:
(local add (def [x int y int : int]
    (return (+ x y))))

; compiles to:
; local add = terra(x : int, y : int) : {int}
;   return (x + y)
; end

(add 1 2) ; returns 3

(local iseven (def [uint32 : bool]))
(local isodd (def [n uint32 : bool]
    (if (= n 0) 
        (return true)
        (return (iseven (- n 1))))))
(iseven:adddefinition (def [n uint32 : bool]
    (if (= n 0)
        (return false)
        (return (isodd (- n 1))))))

; compiles to: 
; local terra iseven :: { uint32 } -> { bool }
; local isodd = terra(n : uint32) : { bool }
;   if n == 0 then return true else return iseven(n - 1) end
; end
; iseven:adddefinition(terra(n : uint32) : { bool }
;   if n == 0 then return false else return isodd(n - 1) end
; end)

To define a function as returning "void", simply end the argument list with a :. To make terra infer the return type, do not include a : in the argument list at all. Undefined functions can't have their return types inferred.

Unlike Fennel, we do not implement implicit return semantics, and early returns are A-OK. Sorry Phil.

q

Defines a terra quotation, compiling down to the ` operator if given one argument, and quote / in / end if given more than one. The resulting quote can always be used as an expression that returns a value; if q is passed more than one argument, the last form is used as the in block. To force no value to be returned, you can pass ($) as the last argument, which corresponds to the empty tuple. Note that quotes inherently creates a new temporary scope, so variables created in a quote will go out of scope by the end of the quote.

(fn inc [x] (q (+ x 1))) ; compiles to: function(x) return `(x + 1) end
(fn do-thing [f x] 
    (q (var z (+ ,x 1)) 
       (f z) 
       ($)))
; compiles to: 
; function(f, x) 
;   return quote
;     var z = [x] + 1
;     f(z)
;   in
;     {}
;   end
; end

Type syntax

In Terra, types are Lua values and can be constructed from regular Lua code, outside of terra or quote blocks. (There are certain places inside these blocks where types can be constructed as well.) However, because Terra significantly extends the syntax of Lua to allow for convenient type construction, Garden also must provide a mini-language to support it.

Inside terra or quote blocks, whenever you have a form that requires a type to be passed to it, the compiler will automatically enter a type-compiling context. Outside of these blocks, the ttype macro can be used.

Pointers and arrays

Pointers to types and arrays of types use the Fennel "sequence table" syntax. Alternatively, the & operator can be used.

(local intptr (ttype [int]))      ; compiles to: &int
(local intptr2 (ttype (& int)))   ; also compiles to: &int
(local intarray (ttype [int 16])) ; compiles to: int[16]

Structs

Structs are defined using the Fennel "key-value table" syntax. For each row in the table, the keys are Fennel symbols representing the name of the field, and the values are type expressions. If, instead of a symbol, the compiler finds the string :union, the value is expected to be another "key-value table" containing the same structure. Struct definitons can be nested.

Note that the field names must be valid Lua symbols; no name-mangling is done. Might be worth doing that at some point; it seems likely that I will be annoyed if we don't auto-convert - to _ at least.

(local Variant (ttype {tag int
                       :union {number float
                               string [int8]
                               complex {real float imag float}}}))
; compiles to:
; local Variant = struct {
;   tag : int,
;   union {                               
;     number : float,
;     string : &int8,
;     complex : struct {
;       real : float,
;       imag : float
;     }
;   }  
; }

Function pointers

Function pointer types are defined with the -> form, which a variable number of types as arguments. Similar to def, you can put a : in your argument list to delineate between input parameters and output types. Unlike def, if no : is present it is assumed that the function does not return a value, as inference is not possible.

(local callback (ttype (-> [int] int : int))) ; compiles to: local callback = { &int, int } -> { int }
(local callback (ttype (-> float)))           ; compiles to: local callback = { float } -> {}

Tuples

Tuple types are defined in Terra with a generic Lua function call to tuple that takes a variable number of types as parameters. This is supported directly, like any type declaration consisting of a function call, but there is a shortened form using $ to match the tuple instantiation syntax.

(ttype (tuple int [int])) ; compiles to: tuple(int, &int)
(ttype ($ int [int]))     ; compiles to: tuple(int, &int)

Escaping

Arbitrary Fennel expressions can be evaluated in a type-compilation context using Fennel's , prefix, which is normally used by macros. If you need to re-enter a type-compilation context after escaping, you'll need to nest a call to ttype. (I'm considering using ` for this purpose, but I might have it consistently mean "create a quote" everywhere. Not sure.)

(ttype ($ [int] (fn-accepting-type [int]) ,(fn-accepting-seq [5]) (fn-accepting-seq-of-types ,[(ttype [int])])))
; compiles to:
; tuple(&int, fn_accepting_type(&int), fn_accepting_seq({ 5 }), fn_accepting_seq_of_types({ &int }))

Terra syntax

var

(var name initial-value)                  ; compiles to: var name = initial-value
(var name type initial-value)             ; compiles to: var name : type = initial-value
(var (name1 name2) val1 val2)             ; compiles to: var name1, name2 = val1, val2
(var {name1 type1 name2 type2} val1 val2) ; compiles to: var name1 : type1, name2 : type2 = val1, val2
(var {undefined type})                    ; compiles to: var undefined : type

Define a local variable named var, and set its initial value to initial-value. You can manually specify a type, or you can let terra infer it from initial-value. Both forms require initial-value to be provided.

You can define multiple type-inferred variables at once with the (name1 name2) syntax, and multiple explicitly-typed variables with the {name1 type1 name2 type2} syntax. Both of those forms accept any number of initial values. If no values are provided, the variables are left uninitialized. If one value is passed, Terra will treat it as a tuple destructuring. Otherwise you should probably pass the same number of values as names.

assignment

(set varname value)            ; compiles to: varname = value
(set struct.field value)       ; compiles to: struct.field = value
(set (field1 field2) tuple)    ; compiles to: field1, field2 = tuple
(tset struct (getfield) value) ; compiles to: struct.[getfield()] = value

Pointers and arrays

Dereferencing a pointer or accessing an element in an array uses the same syntax as defining a pointer or array type - the Fennel sequence literal. To take a reference to a value, you can use the & form.

(def [ptr [int]] (return [ptr]))               ; compiles to: terra (ptr : &int) return @ptr end
(def [arr [int 8]] (return [arr 5]))           ; compiles to: terra (arr : int[8]) return arr[5] end
(def [nested [[int]]] (return [nested 0 3]))   ; compiles to: terra (nested : &&int) return nested[0][3] end
(def [nested [[int]]] (return [[nested] 3]))   ; compiles to: terra (nested : &&int) return (@nested)[3] end
(def [ptr [int] : [int]] (return (& [ptr 1]))) ; compiles to: terra (ptr : &int): &int return &ptr[1] end

field access

struct.field            ; compiles to: struct.field
(struct.func)           ; compiles to: struct.func()
(obj:method)            ; compiles to: obj:method()
(. struct (getfield))   ; compiles to: struct.[getfield()]
(: obj (getmethod))     ; compiles to: struct:[getmethod()]()

cast

(cast type expr)

Cast an expression expr to the type type.

(cast [int] voidptr)                          ; compiles to: ([&int]voidptr)
(cast [int8] (C.malloc (* (sizeof int8) 16))) ; compiles to: ([&int8]C.malloc(sizeof(int8) * 16))

tuple literal

$ can be used to create a tuple.

($ 5 2.5 :hello)                ; compiles to: { 5, 2.5, "hello" }
(var pair ($ int int) ($ 5 10)) ; compiles to: var pair : { int, int } = { 5, 10 }

struct literal

A fennel key-value table literal is interpreted as an anonymous struct literal. If you "call" a struct type with a struct literal, it will coerce it to the given type.

(local Complex (ttype {real float imag float})) 
; compiles to:
; local Complex = struct { real : float, imag : float }

(def [: Complex] (return (Complex { real 5 imag 1 })))
; compiles to:
; terra (): Complex
;   return Complex({ real = 5, imag = 1 })
; end

Primitive operators

Fennel Terra Meaning
(+ x y) x + y add x and y
(- x y) x - y subtract y from x
(/ x y) x / y divide x by y
(* x y) x * y multiply x and y
(% x y) x % y x modulo y
(< x y) x < y x is less than y
(<= x y) x <= y x is less than or equal to y
(= x y) x == y x is equal to y
(not= x y) x ~= y x is not equal to y
(> x y) x > y x is greater than y
(>= x y) x >= y x is greater than or equal to y
(and x y) x and y x AND y (boolean or bitwise)
(or x y) x or y x OR y (boolean or bitwise)
(not x) not x NOT x (boolean or bitwise)
(^ x y) x ^ y x XOR y (bitwise)
(<< x y) x << y arithmetic shift x left by y bits
(>> x y) x >> y arithmetic shift x right by y bits

return

Terra functions can return multiple values, or none. Returns must be explicitly written with the return form.

(def [: int] (return 5))                ; compiles to: terra (): { int } return 5 end
(def [: float float] (return -1.5 1.5)) ; compiles to: terra (): { float float } return -1.5, 1.5 end

Fennel escaping