Home Page: https://terralang.org
Community: https://terralang.zulipchat.com
Replit: https://replit.com/@terralang/terra
Terra is a new low-level system programming language that is designed to interoperate seamlessly with the Lua programming language. It is also backwards compatible with (and embeddable in) existing C code. Like C, Terra is a monomorphic, statically-typed, compiled language with manual memory management. But unlike C, it is designed to make interaction with Lua easy. Terra code shares Lua's syntax and control-flow constructs. It is easy to call Lua functions from Terra (or Terra functions from Lua).
Furthermore, you can use Lua to meta-program Terra code. The Lua meta-program handles details like conditional compilation, namespaces, and templating in Terra code that are normally special constructs in low-level languages. This coupling additionally enables more powerful features like function specialization, lisp-style macros, and manually controlled JIT compilation. Since Terra's compiler is also available at runtime, it makes it easy for libraries or embedded languages to generate low-level code dynamically.
This guide serves as an introduction for programming in Terra. A general understanding of the Lua language is very helpful, but not strictly required.
The fastest way to try Terra is via Replit. You can run Terra in your browser without needing to install anything. Once you get a feel for the language, you can use the instructions below to install Terra on your computer.
Terra currently runs on Linux (x86_64, AArch64, PPC64le), macOS (x86_64, AArch64), FreeBSD (x86_64), and Windows (x86_64). Binary releases for popular versions of these systems are available online, and we recommend you use them if possible because building Terra requires a working install of LLVM and Clang, which can be difficult to get working.
Note that as of macOS 10.15 Catalina, in order to include C headers in Terra you will need to define the following environment variable:
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
Similar to the design of Lua, Terra can be used as a standalone executable/read-eval-print-loop (REPL) and also as a library embedded in a C program. This design makes it easy to integrate with existing projects.
To run the REPL:
$ ./terra
Terra -- A low-level counterpart to Lua
Homepage: https://terralang.org/
Project: https://github.com/terralang/terra
Community: https://terralang.zulipchat.com/
>
Terra's REPL behaves similar to Lua's REPL. If you are familiar with other languages like Python, the one major difference is that expressions must be prefixed with return
or =
if you want to get their value:
> 3 --ERROR! it is expecting a statement
stdin:1: unexpected symbol near 3
> return 3 -- OK!
3
> = 3 -- syntax sugar in the REPL for 'return 3'
3
You can also run it on already written files:
$ ./terra tests/hello.t
hello, world
Terra can also be used as a library from C by linking against libterra_s.a
(windows: terra.dll
). The interface is very similar that of the Lua interpreter.
A simple example initializes Terra and then runs code from the file specified in each argument:
//simple.cpp
#include <stdio.h>
#include "terra.h"
int main(int argc, char ** argv) {
lua_State * L = luaL_newstate(); //create a plain lua state
luaL_openlibs(L); //initialize its libraries
//initialize the terra state in lua
terra_init(L);
for(int i = 1; i < argc; i++)
//run the terra code in each file
if(terra_dofile(L,argv[i]))
return 1; //error
return 0;
}
This program can then be compiled by linking against the Terra library
# Linux
c++ simple.cpp -o simple -I<path-to-terra-folder>/terra/include \
-L<path-to-terra-folder>/lib -lterra_s -ldl -pthread
# OSX
c++ simple.cpp -o simple -I<path-to-terra-folder>/terra/include \
-L<path-to-terra-folder>/lib -lterra_s
In addition to these modes, Terra code can be compiled to .o
files which can be linked into an executable, or even compiled to an executable directly.
A bunch of example scripts can be found in the tests/
directory. The run
script in the directory will run all of these languages tests to ensure that Terra is built correctly.
Terra includes test suite to make sure all of its functionality is working. To run it:
cd tests
../terra run
Expect it to print a lot of junk out. At the end it will summarize the results:
471 tests passed. 0 tests failed.
If the binary releases are not appropriate, then you can also build Terra from source. Terra has the following dependencies:
- A working C/C++ compiler (GCC, Clang, MSVC, etc.) that supports at least C++ 17
- Windows only: Visual Studio 2022 (other versions may work but are untested)
- CMake (version 3.5 or greater)
- Linux, macOS, FreeBSD only: GNU Make (required for building LuaJIT)
- LLVM and Clang (see suppport table below)
- LuaJIT (note this is downloaded and installed automatically by default)
- Optional: CUDA
- Note: In addition to CUDA, Terra also supports AMD and Intel GPUs. However, Terra does not link their GPU toolkits directly so this is not a build requirement.
On recent versions of Ubuntu, you can get these dependencies with:
sudo apt-get install build-essential cmake git llvm-18-dev libclang-18-dev clang-18 libmlir-18-dev libedit-dev libncurses5-dev zlib1g-dev libpfm4-dev
On macOS with Homebrew, the following should be sufficient:
brew install cmake llvm@18
On FreeBSD, use:
pkg install -y cmake gmake llvm18
The current recommended version of LLVM is 18 for most platforms, except Windows (x86) and Linux (ARM) where LLVM 11 is required. The following versions are also supported:
Version | Linux | macOS | FreeBSD | Windows | NVIDIA/CUDA | AMD/HIP * | Intel/SPIRV ** | Notes |
---|---|---|---|---|---|---|---|---|
11 | đź’š | đź’š | đź’š | đź’š | đź’š | |||
12 | đź’š | đź’š | đź’š | đź’š | ||||
13 | đź’š | đź’š | đź’š | đź’š | đź’› | |||
14 | đź’š | đź’š | đź’š | đź’š | đź’› | |||
15 | đź’š | đź’š | đź’š | đź’š | đź’› | đź’› | ||
16 | đź’š | đź’š | đź’š | đź’š | đź’› | đź’› | ||
17 | đź’š | đź’š | đź’š | đź’š | đź’› | đź’š | ||
18 | đź’š | đź’š | đź’š | đź’š | đź’š | đź’š |
* AMD GPU support is currently experimental. LLVM 18 is strongly recommended.
** Intel GPU (SPIR-V) support is currently experimental.
The following versions were previously supported by Terra:
Version | Last Supported Release |
---|---|
3.5 | 1.0.0-beta3 |
3.6 | 1.0.0-beta3 |
3.7 | 1.0.0-beta3 |
3.8 | 1.0.6 |
3.9 | 1.0.6 |
5 | 1.0.6 |
6 | 1.1.1 |
7 | 1.1.1 |
8 | 1.1.1 |
9 | 1.1.1 |
10 | 1.1.1 |
If you need to build LLVM from source, the following recipe has been known to work with Terra. The same basic procedure should work for all LLVM versions >= 11.
wget https://github.com/llvm/llvm-project/releases/download/llvmorg-13.0.0/llvm-13.0.0.src.tar.xz
wget https://github.com/llvm/llvm-project/releases/download/llvmorg-13.0.0/clang-13.0.0.src.tar.xz
tar xf llvm-13.0.0.src.tar.xz
tar xf clang-13.0.0.src.tar.xz
mv clang-13.0.0.src llvm-13.0.0.src/tools/clang
mkdir build install
cd build
cmake ../llvm-13.0.0.src -DCMAKE_INSTALL_PREFIX=$PWD/../install -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_TERMINFO=OFF -DLLVM_ENABLE_LIBEDIT=OFF -DLLVM_ENABLE_ZLIB=OFF -DLLVM_ENABLE_LIBXML2=OFF -DLLVM_ENABLE_ASSERTIONS=OFF
make install -j4 # tune this for how many cores you have
There are also pre-built binaries available that follow this basic recipe, and should be usable with Terra.
The basic procedure for building with CMake is the following:
git clone https://github.com/terralang/terra.git
cd terra/build
cmake -DCMAKE_INSTALL_PREFIX=$PWD/../install ..
make install -j4 # tune this for how many cores you have
This will install Terra into the terra/install
directory.
Please note that on macOS, the following is required to enable the C/C++ compiler (and Terra) to find the system headers:
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
CMake will attempt to auto-detect the location of LLVM on the system. If it is unable to do so (e.g., because LLVM is installed in a non-standard location), this can be specified manually be adding the CMake flag:
-DCMAKE_PREFIX_PATH=/path/to/llvm/install
Similarly, CMake will attempt to auto-detect the presence of CUDA. However, CUDA is optional by default and the build will continue if CUDA is not found. In order to force CUDA on or off, add the following CMake flag:
-DTERRA_ENABLE_CUDA=ON # or OFF
For more details on how CMake detects CUDA, see the FindCUDA documentation.
The following flags are not typically required, but can be used to further configure the Terra build via CMake:
TERRA_ENABLE_CUDA
(default unset): Set this variable to force CUDA on or off.TERRA_STATIC_LINK_LLVM
(defaultON
): Whether to statically link against LLVM or not.TERRA_SLIB_INCLUDE_LLVM
(defaultON
, except on Windows): Whetherlibterra_s.a
should include LLVM or not.TERRA_STATIC_LINK_LUAJIT
(defaultON
): Whether to statically link against LuaJIT or not.TERRA_SLIB_INCLUDE_LUAJIT
(defaultON
, except on Windows): Whetherlibterra_s.a
should include LuaJIT or not.
See the CMake instructions above. The biggest caveats when buildings on Windows are:
-
Run CMake from a Visual Studio Command Prompt. This is necessary for CMake (and Terra) to correctly detect the compiler.
-
After using CMake to configure the build, you can use the following command to build Terra (so as to avoid needing to open the Visual Studio GUI to build it):
cmake --build . --target INSTALL --config Release
Otherwise the CMake build should function (and obey the same variables) as on other OSes.
Note that, even after Terra has been built, it is still necessary to run it from the Visual Studio Command Prompt so that it can detect the location of the system header files.
Hello world is simple:
print("hello, world")
This program is actually a completely valid Lua program as well. The top-level declarations in a Terra source code file are always run as normal Lua code! This top-level Lua layer handles the details like conditional compilation, namespaces, and templating of terra code. We'll see later that it additionally allows for more powerful meta-programming features such as function specialization, and multi-stage programming.
To actually begin writing Terra code, we introduce a Terra function with the keyword terra
:
terra addone(a : int)
return a + 1
end
print(addone(2)) --this outputs: 3
Unlike Lua, arguments to Terra functions are explicitly typed. Terra uses a simple static type propagation to infer the return type of the addone
function. You can also explicitly specify it:
terra addone(a : int) : int
return a + 1
end
The last line of the example invokes the Terra function from the top-level context. This is an example of the interaction between Terra and Lua.
Terra code is JIT compiled to machine code when it is first needed. In this example, this occurs when addone
is called. In general, functions are needed when then are called, or when they are referred to by other functions that are being compiled.
More information on the interface between Terra and Lua can be found in Lua-Terra interaction.
We can also print "hello, world" directly from Terra code like so:
local C = terralib.includec("stdio.h")
terra main()
C.printf("hello, world\n")
end
main()
The function terralib.includec
is a Lua function that invokes Terra's backward compatibility layer to import C code in stdio.h
into the Lua table C
. Terra functions can then directly call the C functions. Since both clang (our C frontend) and Terra target the LLVM intermediate representation, there is no additional overhead in calling a C function. Terra can even inline across these calls if the source of the C function is available!
The local
keyword is a Lua construct. It introduces a locally scoped Lua variable named C
. If omitted it would create a globally scoped variable.
You can also compile code into a .o
, or compile a stand-alone native executable.
We can instruct the Terra compiler to save an object file or executable:
-- save a .o file you can link to normal C code:
terralib.saveobj("hello.o",{ main = main })
-- save a native executable
terralib.saveobj("hello", { main = main })
The second argument is a table of functions to save in the object file and may include more than one function. The implementation of saveobj
is still very primitive. For instance, it won't correctly save Terra functions that invoke Lua functions. This interface will become more robust over time.
Variables in Terra code are introduced with the var
keyword:
terra myfn()
var a : int = 3
var b : double
end
Unlike Lua, all Terra variables must be declared. Initializers are optional. b
's value above is undefined until it is assigned. If an initializer is specified, then Terra can infer the variables type automatically:
terra myfn()
var a = 3.0 --a will have type double
end
You can have multiple declarations on one line:
terra myfn()
var a : int, b : double = 3, 4.5
var c : double, d = 3, 4.5
end
Lua and Terra are both whitespace invariant. However, there is no need for semicolons between statements. The above statement is equivalent to:
terra myfn()
var a : int, b : double = 3, 4.5 var c : double, d = 3, 4.5
end
If you want to put a semicolon in for clarity you can:
terra myfn()
var a : int, b : double = 3, 4.5; var c : double, d = 3, 4.5
end
Assignments have a similar form:
terra myfn()
var a,b = 3.0, 4.5
a,b = b,a
-- a has value 4.5, b has value 3.0
end
As in Lua, the right-hand side is executed before the assignments are performed, so the above example will swap the values of the two variables.
Variables can be declared outside terra
functions as well:
a = global(double,3.0)
terra myfn()
return a
end
This makes a
a global variable that is visible to multiple Terra functions. The global
function is part of Terra's Lua-based API. It initializes a
to the Lua value 3.0
.
Variables in Terra are always lexically scoped. The statement do <stmts> end
introduces a new level of scoping (for the remainder of this guide, the enclosing terra
declaration will be omitted when it is clear we are talking about Terra code):
var a = 3.0
do
var a = 4.0
end
-- a has value 3.0 now
Terra's control flow is almost identical to Lua except for the behavior of for
loops.
If statements in Terra work like Lua.
if a or b and not c then
C.printf("then\n")
elseif c then
C.printf("elseif\n")
else
C.printf("else\n")
end
Terra's while
and repeat
loops work the same as Lua. The optional
keyword break
may be used to exit out of the loop early. Note that,
like Lua, there is no continue
keyword.
var a = 0
while a < 10 do
C.printf("loop\n")
a = a + 1
end
repeat
a = a - 1
C.printf("loop2\n")
until a == 0
while a < 10 do
if a == 8 then
break
end
a = a + 1
end
Terra also includes for
loop. This example counts from 0 up to but not including 10:
for i = 0,10 do
C.printf("%d\n",i)
end
This is different from Lua's behavior (which is inclusive of 10) since Terra uses 0-based indexing and pointer arithmetic in contrast with Lua's 1-based indexing. Ideally, Lua and Terra would use the same indexing rules. However, Terra code needs to frequently do pointer arithmetic and interface with C code both of which are cumbersome with 1-based indexing. Alternatively, patching Lua to make it 0-based would make the flavor of Lua bundled with Terra incompatible with existing Lua code.
The loop may also specify an option step parameter:
for i = 0,10,2 do
c.printf("%d\n",i) --0, 2, 4, ...
end
Experimental. Terra has experimental support for iterators via the __for
metamethod. When a Terra type implements __for
, it can be iterated with the following syntax:
for i in Range {0,10} do
C.printf("%d\n",i)
end
Experimental. Similar to C++ and other languages, Terra provides a
switch
statement that branches between multiple (compile-time
constant) options.
switch expr do
case 1 then
first_thing()
case 2 then
second_thing()
end
end
Note that, unlike C++, case
statements must currently be nested
directly beneath the corresponding switch
; also, no break
statements are required. (If you use break
, it will break out of the
corresponding innermost loop, if any, or otherwise be an error.)
An optional else
may be used to specify a default fall-through case.
switch expr do
case 1 then
first_thing()
end
else
default_thing()
end
The end
on the last case
may be omitted if the switch
statement
has an else
.
switch expr do
case 1 then
first_thing()
else
default_thing()
end
In this case is it idiomatic to dedent the case
because it aligns
with the final end
.
Terra includes goto
statements. Use them wisely. They are included since they can be useful when generating code for embedded languages.
::loop::
C.printf("y\n")
goto loop
The target of a goto
must be a label, either written into the code (as above) or generated programmatically via the API:
local loop = label()
terra yes()
::[loop]::
C.printf("y\n")
goto loop
end
yes()
Experimental. The defer
statement allows a function call to be deferred to the bottom of the containing block of code.
do
var f = C.fopen("some_file.txt", "r")
defer C.fclose(f) -- This file gets closed...
C.fprintf(f, "Hello defer\n")
end -- ... at the end of the block.
We have already seen some simple function definitions. In addition to taking multiple parameters, functions in Terra (and Lua) can return multiple values:
terra sort2(a : int, b : int) : {int,int} --the return type is optional
if a < b then
return a, b
else
return b, a
end
end
terra doit()
-- the multiple returns are returned
-- in a 'tuple' of type {int,int}:
var ab : {int,int} = sort2(4,3)
-- tuples can be pattern matched,
-- splitting them into seperate variables
var a : int, b : int = sort2(4,3)
--now a == 3, b == 4
end
doit()
Multiple return values are packed into a tuples, which can be pattern matched in assignments, splitting them apart into multiple variables.
As mentioned previously, compilation occurs when functions are first needed. In this example, when doit()
is called, both doit()
and sort2
are compiled because doit
refers to sort2
.
Functions are typechecked when they are defined. If they refer to other functions, those functions must already be defined, or you will get an error:
terra iseven(n : uint32) : bool
if n == 0 then
return true
else
-- ERROR! isodd has not been defined
return isodd(n - 1)
end
end
print(iseven)
To make mutual recursion easier, Terra allows multiple functions to be defined at once as long as there are no other Lua statements between the definitions:
terra iseven(n : uint32) : bool
if n == 0 then
return true
else
-- OK! isodd defined at the same time.
return isodd(n - 1)
end
end
terra isodd(n : uint32) : bool
if n == 0 then
return false
else
return iseven(n - 1)
end
end
Alternatively, you can declare a function before defining it:
terra isodd :: uint32 -> bool
terra iseven(n : uint32) : bool
...
end
terra isodd(n : uint32)
...
end
Like Lua function definitions, Terra function definitions can insert directly into Lua tables.
local mytable = {}
terra mytable.myfunction()
C.printf("myfunction in mytable\n")
end
So far, we have been treating terra
functions as special constructs in the top-level Lua code. In reality, Terra functions are actually just Lua values. In fact, the code:
terra foo()
end
Is just syntax sugar for*:
foo = terra()
--this is an anonymous terra function
end
The symbol foo
is just a Lua variable whose value is a Terra function. Lua is Terra's meta-language, and you can use it to perform reflection on Terra functions. For instance, you can ask to see the disassembly for the function:
terra add1(a : double)
return a + a
end
--this is Lua code:
> add1:disas()
definition {double}->{double}
define double @add111(double) {
entry:
%1 = fadd double %0, %0
ret double %1
}
assembly for function at address 0xa2ef030
0: vaddsd XMM0, XMM0, XMM0
4: ret
You can also force a function to be compiled:
add1:compile()
Or look at a textual representation of the type-checked code
> add1:printpretty()
add1 = terra(a : double) : {double}
return a + a
end
* The actual syntax sugar is slightly more complicated to support function declarations. See the API reference for the full behavior.
When the Terra compiler looks up a symbol like add1
it first looks in the local environment of the terra
function. If it doesn't find the symbol, then it simply continues the search in the enclosing (Lua) environment. If the compiler resolves the symbol to a Lua value, then it converts it to a Terra value where possible. Let's look at a few examples:
local N = 4
terra powN(a : double)
var r = 1
for i = 0, N do
r = r * a
end
return r
end
N = 3
--powN still computes the 4th power
Here N
is a Lua value of type number
. When powN
is defined, the value of N
is looked up in the Lua environment and inlined into the function as an int
literal.
Since N
is resolved when powN
is defined, changing N
after powN
is compiled will not change the behavior of powN
. For this reason, it is strongly recommended that you don't change the value of Lua variables that appear in Terra code once they are initialized.
Of course, a single power function is boring. Instead we might want to create specialized versions of 10 power functions:
local mymath = {}
for i = 1,10 do
mymath["pow"..tostring(i)] = terra(a : double)
var r = 1
for i = 0, i do
r = r * a
end
return r
end
end
mymath.pow1(2) -- 2
mymath.pow2(2) -- 4
mymath.pow3(2) -- 8
Here we use the fact that in Lua the select operator on tables (a.b
) is equivalent to looking up the value in table (a["b"]
).
You can call these power functions from a Terra function:
terra doit()
return mymath.pow3(3)
end
Let's examine what happens when this function is compiled. The Terra compiler will resolve the mymath
symbol to the Lua table holding the power functions. It will then see the select operator (the dot in mymath.pow3
). Because mymath
is a Lua table, the Terra compiler will perform this select operator at compile time, and resolve mymath.pow3
to the third Terra function constructed inside the loop. It will then insert a direct call to that function inside doit
. This behavior is a form of partial execution. In general, Terra will resolve any chain of select operations a.b.c.d
on Lua tables at compile time. This behavior enables Terra to use Lua tables to organize code into different namespaces. There is no need for a Terra-specific namespace mechanism!
Recall how we can include C files:
local c = terralib.includec("stdio.h")
terralib.includec
is just a normal Lua function. It builds a Lua table that contains references to the Terra functions that represent calls to (in this case) the standard library functions. We can iterate through the table as well:
for k,v in pairs(c) do
print(k)
end
--output:
fseek
gets
printf
puts
FILE
...
Terra allows you to use many types of Lua values in Terra functions. Here we saw two examples: the use of a Lua number N
into a Terra number, and the use of a Terra function mymath.pow3
in body of doit
. Many Lua values can be converted into Terra values at compile time. The behavior depends on the value, and is described in the compile-time conversions section of the API reference.
Additionally, you may want to declare a Terra function as a locally scoped Lua variable. You can use the local
keyword:
local terra foo()
end
Which is just sugar for:
local foo; foo = terra()
end
Terra's type system closely resembles the type system of C, with a few differences that make it interoperate better with the Lua language.
We've already seen some basic Terra types like int
or double
. Terra has the usual set of basic types:
- Integers:
int
int8
int16
int32
int64
- Unsigned integers:
uint
uint8
uint16
uint32
uint64
- Boolean:
bool
- Floating Point:
float
double
Integers are explicitly sized except for int
and uint
which should only be used when the particular size is not important. Most implicit conversions from C are also valid in Terra. The one major exception is the bool
type. Unlike C, all control-flow explicitly requires a bool
and integers are not explicitly convertible to bool
.
if 3 then end -- ERROR 3 is not bool
if 3 == 0 then end -- OK! 3 == 0 is bool
You can force the conversion from int
to bool
using an explicit cast:
var a : bool = [bool](3)
Primitive types have the standard operators defined:
- Arithmetic:
- + * / %
- Comparison:
< <= > >= == ~=
- Logical:
and or not
- Bitwise:
and or not ^ << >>
These behave the same C except for the logical operators, which are overloaded based on the type of the operators:
true and false --Lazily evaluated logical and
1 and 3 --Eagerly evaluated bitwise and
Pointers behave similarly to C, including pointer arithmetic. The syntax is slightly different to work with Lua's grammar:
var a : int = 1
var pa : &int = &a
@pa = 4
var b = @pa
You can read &int
as a value holding the address of an int
, and @a
as the value at address a
. To get a pointer to heap-allocated memory you can use stdlib's malloc
:
C = terralib.includec("stdlib.h")
terra doit()
var a = [&int](C.malloc(sizeof(int) * 2))
@a,@(a+1) = 1,2
end
Indexing operators also work on pointers:
a[3] --syntax sugar for @(a + 3)
Pointers can be explicitly cast to integers that are large enough to hold them without loss of precision. The intptr
is the smallest integer that can hold a pointer. The ptrdiff
type is the signed integer type that results from subtracting two pointers.
You can construct statically sized arrays as well:
var a : int[4]
a[0],a[1],a[2],a[3] = 0,1,2,3
In contrast to Lua, Terra uses 0-based indexing since everything is based on offsets. &int[3]
is a pointer to an array of length 3. (&int)[3]
is an array of three pointers to integers.
The function array
will construct an array from a variable number of arguments:
var a = array(1,2,3,4) -- a has type int[4]
If you want to specify a particular type for the elements of the array you can use arrayof
function:
var a = arrayof(int,3,4.5,4) -- a has type int[3]
-- 4.5 will be cast to an int
Vectors are like arrays, but also allow you to perform vector-wide operations:
terra saxpy(a :float, X : vector(float,3), Y : vector(float,3),)
return a*X + Y
end
They serve as an abstraction of the SIMD instructions (like Intel's SSE or Arm's NEON ISAs), allowing you to write vectorized code. The constructors vector
and vectorof
create vectors, and behave similarly to arrays:
var a = vector(1,2,3,4) -- a has type vector(int,4)
var a = vectorof(int,3,4.5,4) -- a has type vector(int,3)
-- 4.5 will be cast to an int
You can create aggregate types using the struct
keyword. Structs must be declared outside of Terra code:
struct Complex { real : float; imag : float; }
terra doit()
var c : Complex
c.real = 4
c.imag = 5
end
Unlike C, you can use the select operator a.b
on pointers. This has the effect of dereferencing the pointer once and then applying the select operator (similar to the ->
operator in C):
terra doit(c : Complex)
var pc = &c
return pc.real --sugar for (@pc).real
end
Like functions, symbols in struct definitions are resolved when the struct is defined. Mutually recursive structs are fine as long as the definitions are placed back to back with no other intervening Lua statements. Otherwise, the struct can be declared prior to its definition to allow this.
struct C --declaration
struct A {
b : &B
}
-- ok, no intervening Lua statements
struct B {
a : &A
c : &C
}
-- you can mix struct and function definitions
terra myfunc()
end
struct C { i : int }
Terra has no explicit union type. Instead, you can declare that you want two or more elements of the struct to share the same memory:
struct MyStruct {
a : int; --unique memory
union {
b : double; --memory for b and c overlap
c : int;
}
}
In Terra you can also tuples, which are a special kind of struct that contain a list of elements:
var a : tuple(float,float) -- a pair of floats
You can use a constructor syntax to quickly generate tuple values:
var a = { 1,2,3,4 } --has type tuple(int,int,int,int)
Tuples can be cast to other struct types, which will initialize fields of the struct in order:
var c = Complex { 3,4 }
You can also add names to constructor syntax to create anonymous structs, similar to those in languages such as C-sharp:
var b = { a = 3.0, b = 3 }
Terra allows you to cast any anonymous struct to another struct that has a superset of its fields.
struct Complex { real : float, imag : float}
var c = Complex { real = 3, imag = 1 }
Since constructors like {1,2}
are first-class values, they can appear anywhere a Terra expression can appear. This is in contrast to struct initializers in C, which can only appear in a struct declaration.
Terra also allows for function pointers:
terra add(a : int, b : int) return a + b end
terra sub(a : int, b : int) return a - b end
terra doit(usesub : bool, v : int)
var a : {int,int} -> int
if usesub then
a = sub
else
a = add
end
return a(v,v)
end
Terra does not have a void
type. Instead, functions may return zero arguments:
terra zerorets() : {}
end
Earlier we saw how Terra functions were actually Lua values. The same is true of Terra's types. In fact, all type expressions -- expressions following a ':' in declarations -- are simply Lua expressions that resolve to a type. Any valid Lua expression (e.g. function calls) can appear as a type as long as it evaluates to a valid Terra type:
function Complex(typ)
return struct { real : typ, imag : typ }
end
terra doit()
var intcomplex : Complex(int) = {1,2}
var dblcomplex : Complex(double) = { 1.0, 2.0 }
end
Since types are just Lua expressions they can occur outside of Terra code. Here we make a type alias for a pointer to an int
that can be used in Terra code:
local ptrint = &int
terra doit(a : int)
var pa : ptrint = &a
end
In fact many primitive types are just defined as Lua variables:
_G["int"] = int32
_G["uint"] = uint32
_G["long"] = int64
_G["intptr"] = uint64 --these may be architecture specific
_G["ptrdiff"] = int64
Making types Lua objects enables powerful behaviors such as templating. Here we create a template that returns a constructor for a dynamically sized array:
function Array(typ)
return terra(N : int)
var r : &typ = [&typ](C.malloc(sizeof(typ) * N))
return r
end
end
local NewIntArray = Array(int)
terra doit(N : int)
var my_int_array = NewIntArray(N)
--use your new int array
end
Here are some example literals:
3
is anint
3.
is adouble
3.f
is afloat
3LL
is anint64
3ULL
is auint64
"a string"
or[[ a multi-line long string ]]
is an&int8
nil
is the null pointer for any pointer typetrue
andfalse
arebool
When a function returns multiple values, it implicitly creates a tuple of those values as the return type:
terra returns2() return 1,2 end
terra example()
var a = returns2() -- has type tuple(int,int)
C.printf("%d %d\n",a._0,a._1)
end
To make it easier to use functions that return multiple values, we allow a tuple that is the last element of an expression list to match multiple variables on the left the left-hand size.
terra example2()
var a,b,c = 1,returns2()
var a,b,c = returns2(),1 --Error: returns2 is not the last element
end
Unlike languages like C++ or Scala, Terra does not provide a built-in class system that includes advanced features like inheritance or sub-typing. Instead, Terra provides the mechanisms for creating systems like these, and leaves it up to the user to choose to use or build such a system. One of the mechanisms Terra exposes is a method invocation syntax sugar similar to Lua's :
operator.
In Lua, the statement:
receiver:method(arg1,arg2)
is syntax sugar for:
receiver.method(receiver,arg1,arg2)
The function method
is looked up on the object receiver
dynamically. In contrast, Terra looks up the function statically at compile time. Since the value of the receiver
expression is not know at compile time, it looks up the method on its type.
In Terra, the statement:
receiver:method(arg1,arg2)
where receiver
has type T
is syntax sugar for:
T.methods.method(receiver,arg1,arg2)
T.methods
is the method table of type T
. Terra allows you to add methods to the method tables of named structural types:
struct Complex { real : double, imag : double }
Complex.methods.add = terra(self : &Complex, rhs : Complex) : Complex
return {self.real + rhs.real, self.imag + rhs.imag}
end
terra doit()
var a : Complex, b : Complex = {1,1}, {2,1}
var c = a:add(b)
var ptra = &a
var d = ptra:add(b) --also works
end
The statement a:add(b)
will normally desugar to Complex.methods.add(a,b)
. Notice that a
is a Complex
but the add
function expects a &Complex
. If necessary, Terra will insert one implicit address-of operator on the first argument of the method call. In this case a:add(b)
will desugar to Complex.methods.add(&a,b)
.
Like the .
selection operator, the :
method operator can also be used directly on pointers. In this case, the pointer is first dereferenced, and the normal rules for methods are applied. For instance, when using the :
operator on a value of type &Complex
(e.g. ptra
), it will first insert a dereference and desugar to Complex.methods.add(@a,b)
. Then to match the type of add
, it will apply the implicit address-of operator to get Complex.methods.add(&@a,b)
. This allows a single method definition to take as an argument either a type T
or a pointer &T
, and still work when the method is called on value of type T
or type &T
.
To make defining methods easier, we provide a syntax sugar.
terra Complex:add(rhs : Complex) : Complex
...
end
is equivalent to
terra Complex.methods.add(self : &Complex, rhs : Complex) : Complex
...
end
Terra also support metamethods similar to Lua's operators like __add
, which will allow you to overload operators like +
on Terra types, or specify custom type conversion rules. See the API reference on structs for more information.
We've already seen examples of Lua code calling Terra functions. In general, you can call a Terra function anywhere a normal Lua function would go. When passing arguments into a terra function from Lua they are converted into Terra types. The current rules for this conversion are described in the API reference. Right now they match the behavior of LuaJIT's foreign-function interface. Numbers are converted into doubles, tables into structs or arrays, Lua functions into function pointers, etc. Here are some examples:
struct A { a : int, b : double }
terra foo(a : A)
return a.a + a.b
end
assert( foo( {a = 1,b = 2.3} )== 3.3 )
assert( foo( {1,2.3} ) == 3.3)
assert( foo( {b = 1, a = 2.3} ) == 3 )
More examples are in tests/luabridge*.t
.
It is also possible to call Lua functions from Terra. Again, the translation from Terra objects to Lua uses LuaJITs conversion rules. Primitive types like double
will be converted to their respective Lua type, while aggregate and derived types will be boxed in a LuaJIT ctype
that can be modified from Lua:
function add1(a)
a.real = a.real + 1
end
struct Complex { real : double, imag : double }
terra doit()
var a : Complex = {1,2}
add1(&a)
return a
end
a = doit()
print(a.real,a.imag) -- 2 1
print(type(a)) -- cdata
The file tests/terralua.t
includes more examples. The file tests/terraluamethod.t
also demonstrate using Lua functions inside the method table of a terra object.
since we cannot determine the Terra types that function will return, Lua functions do not return values to Terra functions by default. To convert a Lua function into a Terra function that does return a value, you first need to cast
it to a Terra function type:
function luaadd(a,b) return a + b end
terraadd = terralib.cast( {int,int} -> int, luaadd)
terra doit()
return terraadd(3,4)
end
In this guide we've already encountered instances of meta-programming, such as using a Lua loop to create an array of Terra pow
functions. In fact, Terra includes several operators that it make it possible to generate any code at runtime. For instance, you can implement an entire compiler by parsing an input string and constructing the Terra functions that implement the parsed code.
The operators we provide are adapted from multi-stage programming. An escape allows you to splice the result of a Lua expression into Terra. A quote allows you to generate a new Terra statement or expression which can then be spliced into Terra code using an escape. Symbol objects allow you to create unique names at compile time. Finally, a macro can be used like a function call in Terra code but will be evaluated at compile-time. We'll look at each of these operators in detail.
Escapes allow you to splice the result of a Lua expression into Terra code. Here is an example:
function get5()
return 5
end
terra foobar()
return [ get5() + 1 ]
end
foobar:printpretty()
> output:
> foobar0 = terra() : {int32}
> return 6
> end
When the function is defined, the Lua expression inside the brackets ([]
) is evaluated to the Lua value 6
which is then used in the Terra code. The Lua value is converted to a Terra value based on the rules for compile-time conversions in the API reference (e.g. numbers are converted to Terra constants, global variables are converted into references to that global).
Escapes can appear where any expression or statement normally appears. When they appear as statements or at the end of en expression list, multiple values can be spliced in place by returning a Lua array:
terra return123()
--escape appends 2 values:
return 1, [ {2,3} ]
end
You can also use escapes to programmatically choose fields or functions:
local myfield = "foo"
local mymethod = "bar"
terra fieldsandfunctions()
var fields = myobj.[myfield]
var methods = myobj:[mymethod]()
end
Lua expressions inside an escape can refer to the variables defined inside a Terra function. For instance, this example chooses which variable to return based on a Lua parameter:
local choosefirst = true
local function choose(a,b)
if choosefirst then
return a
else
return b
end
end
terra doit(a : double)
var first = C.sin(a)
var second = C.cos(a)
return [ choose(first,second) ]
end
Since Lua and Terra can refer to the same set of variables, we say that they share the same lexical scope.
What values should first
and second
have when used in an escape? Since escapes are evaluated when a function is defined, and not when a function is run, we don't know the results of the sin(a)
and cos(a)
expressions when evaluating the escape. Instead, first
and second
will be symbols, an abstract data type representing a unique name used in Terra code. Outside of a Terra expression, they do not have a concrete value. However, when placed in a Terra expression they become references to the original variable. Going back to the example, the function doit
will return either the value of C.sin(a)
or C.cos(a)
depending on which symbol is returned from the choose
function and spliced into the code.
Previously, we have seen that you can use Lua symbols directly in the Terra code. For example, we looked at this powN
function:
local N = 4
terra powN(a : double)
var r = 1
for i = 0, N do
r = r * a
end
return r
end
This behavior is actually just syntax sugar for an escape expression. In Terra, any name used in an expression (e.g. a
or r
) is treated as if it were an escape. Here is the same function de-sugared:
local N = 4
terra powN(a : double)
var r = 1
for i = 0, N do
r = [r] * [a]
end
return [r]
end
In this case [a]
will resolve to the value 4
and then be converted to a Terra constant, while [r]
will resolve to a symbol and be converted to a reference to the variable definition of r
on the first line of the function.
The syntax sugar also extends to field selection expressions such as a.b.c
. In this case, if both a
and b
are Lua tables, then the expression will de-sugar to [a.b.c]
. For instance, the call to C.sin
and C.cos
are de-sugared to [C.sin]
and [C.cos]
since C
is a Lua table.
A quote allows you to generate a single Terra expression or statement outside of a Terra function. They are frequently used in combination with escapes to generate code. Quotes create the individual expressions and escapes are used stitch them together.
function addone(a)
--return quotation that
--represents adding 1 to a
return `a + 1
end
terra doit()
var first = 1
--call addone to generate
--expression first + 1 + 1
return [ addone(addone(first)) ]
end
If you want to create a group of statements rather than expressions, you can using the quote
keyword:
local printit = quote
C.printf("a quotestatement")
end
terra doit()
--print twice
printit
printit
end
The quote
keyword can also include an optional in
statement that creates an expression:
myquote = quote
var a = foo()
var b = bar()
in
a + b
end
When used as an expression this quote will produce that value.
terra doit()
var one,two = myquote
end
When a variable is used in an escape, it is sometimes ambiguous what value it should have. For example, consider what value this function should return:
function makeexp(arg)
return quote
var a = 2
return arg + a
end
end
terra client()
var a = 1;
[ makeexp(a) ];
end
The variable name a
is defined twice: once in the function and once in the quotation. A reference to a
is then passed the makeexp
function, where it is used inside the quote after a
is defined. In the return statement, should arg
have the value 1
or 2
? If you were using C's macro preprocessor, the equivalent statement might be something like
#define MAKEEXP(arg) \
int a = 2; \
return arg + a; \
int scoping() {
int a = 1;
MAKEEXP(a)
}
In C, the function would return 4
. But this seems wrong -- MAKEEXP
may have been written by a library writer, so the writer of scoping
might not even know that a
is used in MAKEEXP
. This behavior is normally call unhygienic since it is possible for the body of the quotation to accidentally redefine a variable in the expression. It makes it difficult to write reusable functions that generate code and is one of the reasons macros are discouraged in C.
Instead, Terra ensures that variable references are hygienic. The reference to a
in makeexp(a)
refers uniquely to the definition of a
in the same lexical scope (in this case, the definition of a
in the client
function). This relationship is maintained regardless of where the symbol eventually ends up in the code, so the scoping
function will correctly return the value 3
.
This hygiene problem occurs in all languages that have meta-programming. Wikipedia has more discussion. By maintaining hygiene and using lexical scoping, we guarantee that you can always inspect a string of Terra code and match variables to their definitions without knowing how the functions will execute.
For the most part, hygiene and lexical scoping are good properties. However, you may want to occasionally violate lexical scoping rules when generating code. For instance, you may want one quotation to introduce a local variable, and another separate quotation to refer to it. Terra provides a controlled way of violating lexical scoping using the symbol()
function, which returns a unique variable name (a symbol) each time it is called (this is the Terra equivalent of Common Lisp's gensym
). Here is an example that creates a new symbol, defines the symbol in one quotation and then uses it in another.
local a = symbol()
defineA = quote
var [a] = 3
end
twiceA = `2*a
terra doit()
defineA
return twiceA
end
The symbol function can also take a type as an argument symbol(int)
. This has the same effect as when you write var a : int
in a declaration. It is optional when the type of the definition can be inferred (e.g. when it is local variable with an initializer), but required when it cannot be inferred (e.g. when it is a parameter to a function).
Notice that the declaration of the symbol uses the escape [a]
in place of a
. Using just a
would make a local variable with name a
that is not in scope outside of that quotation. In this context, the escape instructs the Terra compiler to parse that part as a Lua expression, evaluate it, and drop the result in place. In this case, the result of evaluation a
is the symbol generated by the symbol()
function. Similarly the reference to a
in the expression 2*a
will evaluate to the same symbol object. If we had omitted the escape, then we would receive a compilation error reporting that 2*a
refers to an undefined variable.
A list of symbols can also be spliced onto the end of parameter lists to generate functions with a configurable number of arguments:
local rest = {symbol(int),symbol(int)}
terra doit(first : int, [rest])
return first + [rest[1]] + [rest[2]]
end
By default, when you call a Lua function from Terra code, it will execute at runtime, just like a normal Terra function. It is sometimes useful for the Lua function to execute at compile time instead. Calling the Lua function at compile-time is called a macro since it behaves similarly to macros found in Lisp and other languages. You can create macro using the function macro
which takes a normal Lua function and returns a macro:
local times2 = macro(function(ctx,tree,a)
return `a + a
end)
terra doit()
var a = times2(3)
-- a == 6
end
Unlike a normal function, which works on Terra values, the arguments to Terra macros are passed to the macro as quotes.
The first argument to every macro is the compilation context ctx
. It can be used to report an error if the macro doesn't apply to the arguments given, and is needed in certain API calls used in macros. The second argument to every macro (tree
) is the AST node in the code that represents the macro call. It is typically used as the location at which to report an error in a macro call. The following code will cause the compiler to emit an error referring to the macro call with given error message:
ctx:reporterror(tree, "something in the macro went wrong")
The remaining arguments to the macro are the AST nodes of the arguments to the macro function.
Since macros take quotes rather than values, they have different behavior than function calls. For instance:
var c = 0
terra up()
c = c + 1
return c
end
terra doit()
return times2(up()) --returns 1 + 2 == 3
end
The example returns 3
because up()
is evaluated twice
Some built-in operators are implemented as macros. For instance the sizeof
operator just inserts a special AST node that will calculate the size of a type:
sizeof = macro(function(ctx,tree,typ)
return terralib.newtree(tree,{ kind = terra.kinds.sizeof,
oftype = typ:astype()})
end)
terra.newtree
creates a new node in this AST. For the most part, macros can rely on code quotations to generate AST nodes, and only need to fallback to explicitly creating AST nodes in special cases.
Macros can also be used to create useful patterns like a C++ style new operator:
new = macro(function(ctx,tree,typquote)
local typ = typquote:astype()
return `[&typ](C.malloc(sizeof(typ)))
end)
terra doit()
var a = new(int)
end
You may be wondering why Terra includes both macros and escapes. They both allow you to splice Terra code into other expressions, and in some cases you can use either a macro or an escape to accomplish the same purpose. Since macros look like function calls, they are normally used when it is not important for the end-user to know that the functionality is implemented by generating code. For instance, in myobj:mymethod(arg)
, mymethod
can be implemented as a macros. Furthermore, while escapes are evaluated when a function is defined (that is, when the surround Lua code executes), macros are run when a function is compiled, which only happens when a function is actually called. This means that macros have access to the types of expressions via the myquote:gettype()
method call.
More details about the interaction of Terra and Lua can be found in the API reference. The best place to look for more examples of Terra features is the tests/
directory, which contains the set of up-to-date languages tests for the implementation. The tests/libs
folder contains some examples of meta-programming such as class systems.
If you are interested in the implementation, you can also look at the source code. The compiler is implemented as a mixture of Lua code and C/C++. Passing the -v
flag to the interpreter will cause it to give verbose debugging output.
-
lparser.cpp
is an extended version of the Lua parser that implements Terra parsing. It parsers Terra code, building the Terra AST for Terra code, while passing the remaining code to Lua (use-vv
to see what is passed to Lua). -
terralib.lua
contains the Lua infrastructure for the Terra compiler, which manages the Terra objects like functions and types. It also performs type-checking on Terra code before compilation. -
tcompiler.cpp
contains the LLVM-based compiler that translates the Terra AST into LLVM IR that can then be JIT compiled to native code. -
tcwrapper.cpp
contains the Clang-based infrastructure for including C code. -
terra.cpp
contains the implementation of Terra API functions liketerra_init
-
main.cpp
contains the Terra REPL (based on the Lua REPL).