The StingType Language

This is the reference for StingType as gstc compiles it. It says what each piece of the language means. README.md is the tour; this file is the rulebook.

StingType sits a little above C: the same machine model (raw memory, pointers, structs laid out like C's, no garbage collector, no hidden allocations), with the mistakes C lets through caught by the compiler, or stopped at run time with a message.


1. Source files

  • A program is a .st file. A library is a .sth file, brought in with include. Both are UTF-8 text.
  • Statements end at the end of a line. There are no semicolons. An expression continues onto the next line when the line ends with an operator or a comma. A line that starts with an operator, (, [ or . always begins a new statement.
  • Comments run from // to the end of the line.
  • Blocks are closed by words, not braces: end, endfn, endstruct, endunion, endenum, until (closing repeat), and endshader.
  • Top-level statements are the main program. They run in order, top to bottom. Functions and types may be declared anywhere at the top level, and may be used before the place where they are declared.

1.1 Words

Reserved words:

if then elseif else end   while do for in repeat until
break continue return stop goto   choose is verify
fn endfn extern struct endstruct union endunion enum endenum
var const call as include sizeof offsetof alignof true false
var_i8 var_i16 var_i32 var_i64 uvar_i8 uvar_i16 uvar_i32 uvar_i64
var_f32 var_f64 var_bool var_str

These words are special only where they are listed, and are ordinary names everywhere else:

Word Where it is special
hidden before a top-level declaration
packed before struct
stdcall, cdecl before fn or extern
new right after a function's :
to in a slice, or a choose range
shader, endshader at the top level

A reserved word (other than true/false) can still be a field name, because a field name always sits where only a name can go: in a struct or union (end: i32), after a . (r.end), and in a literal (Range(start: 1, end: 5)). It can't be a variable or function name.

Names are letters, digits and _. They do not start with a digit. Names starting with __ belong to the compiler and runtime.

1.2 Literals

Form Examples Notes
decimal integer 42, 1_000_000 _ may separate digits
hex, binary, octal 0xFF, 0b1010, 0o755
float 3.14, 1e-9, 2.5E10 always has . or an exponent
character 'A', '\n', 'é' an integer: the byte, or the Unicode code point
string "hi\tthere\n" type str
bool true, false

The escapes in strings and characters are \n \t \r \0 \\ \' \" and \xHH (one byte, by its hex value). A string may not span lines.

An integer literal has no fixed type of its own. It takes the type its place needs (var b: u8 = 200, x + 1 where x is u16), and that type must hold its value; var b: u8 = 300 is an error. With nothing to go by, it is i32, or i64 when it does not fit in i32. A float literal works the same way, between f32 and f64 (f64 by default).


2. Types

Type What it is
i8 i16 i32 i64 signed integers (two's complement)
u8 u16 u32 u64 unsigned integers
isize usize integers as wide as a pointer (64 or 32 bits, by target)
f32 f64 IEEE 754 floats
bool true / false, one byte
str text: a pointer and a length, read-only
*T a pointer to a T
[N]T an array: exactly N Ts, in place
[]T a slice: a pointer to some Ts, and how many
fn(A, B): R a function value (a real machine-code address); its parameters can be any type, its result a number, bool, pointer or function
struct, union, enum declared types (section 4)
Name<A, B> a generic struct, with its type arguments

No silent conversions. Two values meet in an operation only when they have the same type. i32 + i64, u64 = usize and f64 = i32 are all errors. You convert with as:

var big: i64 = small as i64
var p: *Header = raw as *Header
var addr: usize = p as usize

as converts between any two numeric types (integers are truncated or extended, floats rounded toward zero to integers), between pointers, between pointers and integers, between function values and integers or pointers, and from an enum to its integer value.

Sizes follow C on each target: sizeof(T), alignof(T) and offsetof(T, field) give them as u64, while compiling.

str is a view of bytes: s[i] is a u8 (bounds-checked), and str_len(s) is how many bytes there are. String literals also end with a 0 byte, so str_ptr("...") is a ready C string. A str that a function made new (marked : new str) is yours to str_free.

Arrays are values. Assigning one copies it, and a.len is its length. Arrays of the same element type and length can be assigned to each other.

Slices point into an array, a heap block or another slice: s.len, s[i] (bounds-checked), for x in s. An array becomes a slice where a slice is wanted.


3. Expressions

3.1 Operators, from loosest to tightest

Level Operators
1 \|\|
2 &&
3 == != < > <= >=
4 \|
5 ^
6 &
7 << >>
8 + -
9 * / %
10 as
11 unary - ~ ! & *
12 .field, [index], [a until b], (call)

The bitwise operators bind tighter than comparisons, so flags & 4 != 0 means (flags & 4) != 0. Everything is left-associative. && and || evaluate their right side only when they need it. The operands of every other operator are evaluated left first.

3.2 Integer arithmetic is checked

+ - * stop the program if the true result does not fit in the type. So do / and % by zero, and MIN / -1. << and >> stop the program when the shift amount is not between 0 and the width minus 1. / truncates toward zero, and % takes the sign of the left side (like C). >> is arithmetic for signed types and logical for unsigned ones.

When arithmetic should wrap, say so: wrap_add, wrap_sub and wrap_mul work for any integer type. For results that may not fit, checked_add, checked_sub and checked_mul in standard.sth report failure instead of stopping the program.

An operation on two plain numbers is worked out while compiling (KIB * 64, (1 << 12) - 1). This happens only when the result is exactly what running it would give. An operation that would stop the program is left in, so it still stops when it runs.

3.3 Floats

+ - * / and comparisons follow IEEE 754. Any comparison with NaN is false, except !=. % does not work on floats; use fmod_f64.

3.4 Pointers

Expression Meaning
&x the address of a variable, field or element
*p what p points at
p.field (*p).field
p[i] element i, as in C (unchecked)
p + n, p - n move n whole elements
p - q the distance between two pointers, in elements

Using a null pointer stops the program; it does not crash the machine. Pointer arithmetic and indexing through a raw pointer are not bounds checked. There is no length to check them against.

3.5 Slicing

x[a until b] is elements a up to but not including b. x[a to b] is a through b, inclusive. Slicing a str gives a str. Slicing an array, slice or pointer gives a []T. A slice that starts after it ends, or reaches past the end of what it is cut from, stops the program.

3.6 Calls and literals

  • f(a, b) calls a function. Arguments are evaluated left to right.
  • Point(x: 1, y: 2) builds a struct. Every field must be given. (A variable declared with no value, var p: Point, is all zeros.)
  • [1, 2, 3] builds an array.
  • Color.Red is an enum variant.
  • ns.name reaches into a library included as ns.

4. Declarations

4.1 Variables and constants

var count = 0                  // type from the value: i32
var ratio: f64 = 0.5
var buf: [256]u8               // no value: zero
const LIMIT = 4096             // can't be assigned again
var_i64 total = 0              // the type built into the word

Every variable starts as zero (or its value) — there is no uninitialized memory. A variable lives from its declaration to the end of its block. Top-level variables are globals. Constants of integer type can be used where a constant is needed (verify, choose, array sizes).

4.2 Functions

fn area(w: i32, h: i32): i32
  return w * h
endfn

fn log_line(msg: str)          // no ':' -- returns nothing
  print_str(msg)
endfn
  • Parameters are passed by value; structs and arrays are copied. Pass a pointer to share.
  • A function with a result must return on every path. The compiler checks this.
  • : new T marks a result as newly made (heap memory, a new str), never part of what was passed in. The lifetime checker relies on this (section 8), and the compiler holds the function to it.
  • Recursion is allowed. Running out of stack stops the program with a message, including on threads.

Generic functions take type parameters, and are copied out for each set of types used:

fn biggest<T>(a: T, b: T): T
  if a > b then
    return a
  end
  return b
endfn
var m = biggest(3, 9)               // T = i32, worked out from the arguments

The types are worked out from the arguments, and when no argument says, from where the result goes (var x: i64 = zero()). Or write them out, in order, between < and > right after the name:

fn zero<T>(): T
  return 0 as T
endfn
var a = zero<u8>()
var l = make_list<List<i32>>()       // nested: >> is fine
var p = std.max<f64>(1.0, 2.5)      // through a library's name too

Giving type arguments to a function that isn't generic, or the wrong number of them, is an error. Function values work with generics, so a library can take your order: sort_by(s, less) with less: fn(T, T): bool -- for any T, structs included.

4.3 Structs, unions and enums

struct Point
  x: i32
  y: i32
endstruct

packed struct Wire                  // no padding: 7 bytes
  tag: u8
  size: u32
  flags: u16
endstruct

union Number                        // every field at offset 0
  i: i64
  f: f64
endunion

enum Color                          // i32 values from 0, or as given
  Red
  Green = 5
  Blue                              // 6
endenum

struct Pair<A, B>                   // generic
  first: A
  second: B
endstruct

A struct is laid out exactly as a C compiler lays out the same fields on that target. That makes structs safe to hand to C libraries and to the OS. A union literal sets exactly one field: Number(f: 1.0).

4.4 Visibility: hidden

hidden before a top-level fn, struct, union, enum, var, const or extern means only its own file can use it. Libraries use it for their helpers. A hidden name in one library never clashes with the same name elsewhere.

4.5 Libraries: include

include "lib/standard.sth"                 // its names, directly
include "lib/mathlib.sth" as math          // its names as math.sin, math.Vec2

A path is looked up in this order:

  1. relative to the including file
  2. in --lib=DIR folders
  3. in the GSTC_LIB folders
  4. in a lib folder next to gstc
  5. in gstc's own built-in copy of the standard libraries

The built-in libraries are standard.sth, mathlib.sth, collections.sth, text.sth, time.sth, system.sth, net.sth, threads.sth, testing.sth, sound.sth, random.sth, hash.sth, encoding.sth, json.sth and regex.sth, plus the graphics and media libraries. A file included twice is included once.

A file may be included under different names by different files (or plainly by one and named by another): it's still one library -- one copy of its code, its globals and its types -- and every name reaches it. So a library can include what it needs under its own name (include "standard.sth" as json_std) without caring what the program calls it. The library takes one name inside the compiler: plain if any file includes it plainly, otherwise the main program's name for it, otherwise the first one used; error messages use that name.

An unnamed include inside a library that was itself included as ns belongs to ns too -- a library's own helper files are part of it. The shared libraries are the exception: a library found in a library folder (--lib, GSTC_LIB, the lib next to gstc, or gstc's built-in set) and included without a name is the one, program-wide copy, whoever includes it. So two libraries that both use text.sth share it, and the program can use it too.

4.6 Outside code: extern

extern "libc.so.6" fn strlen(s: *u8): usize
stdcall extern "kernel32.dll" fn win_sleep(ms: u32) as "Sleep"

This declares a function in a shared library (.so, .dll), or in a static library (.a, .o) that gets linked in. as "Symbol" gives the library's own name for the function. Parameters and results are numbers, bool, pointers and function values. A struct goes by pointer, and so does a struct parameter of a callback given to C. Calls follow each platform's C calling convention. (Between StingType functions, a function value's struct, array, str or slice parameter travels as a pointer to a copy.)


5. Statements

Statement Meaning
x = v assign (the types must match)
x += v x = x + v (also -= *= /= %= &= \|= ^= <<= >>=); the place may not contain a call
f(x) / call f(x) call, ignoring any result
call f(x) -> y y = f(x)
if c then ... elseif c then ... else ... end conditions must be bool
while c do ... end
repeat ... until c the body runs at least once
for i = a, b do ... end i from a through b, step 1
for i = a, b, s do ... end the same, stepping by s (which may be negative)
for x in arr do ... end each element of an array or slice
break, continue the innermost loop
return / return v leave the function
::name:: / goto name a label, and a jump to it within the same function
stop / stop CODE end the program now, with exit status 0 or CODE
choose see 5.1
verify see 5.2

5.1 choose

choose ch
  is 'a' to 'z', 'A' to 'Z' then
    kind = 1
  is '0' to '9' then
    kind = 2
  is ' ', '\t' then
    kind = 3
  else
    kind = 0
end

The subject is evaluated once. Each case lists values (is 1, 2, 3) and ranges (A to B includes B; A until B stops before it). The values must be constants of the subject's type. The first case that matches runs, and only that one; there is no falling through.

The compiler checks the cases while compiling:

  • A value listed twice, or ranges that overlap, is an error. The error names the values.
  • Choosing on an enum with no else must list every variant. The error names the ones missing.

5.2 verify

verify sizeof(Header) == 12, "Header must match the file format"
verify PAGE % 4096 == 0

The condition is proven while compiling and produces no code. It must be a constant expression. If it is false, compiling stops with the message.


6. Memory

Built-in What it does
alloc(n) n zeroed bytes from the heap, as *u8
realloc_mem(p, n) grow or shrink a block, keeping its contents
dealloc(p) give a block back (null is ignored)

The heap is StingType's own: size classes for small blocks, and the OS for big ones. It is safe to use from several threads at once. Freeing a block twice stops the program with a message.

Nothing is freed for you, and there is no garbage collector. A function whose result is : new T hands you something to free. collections.sth's list_free and map_free, text.sth's text_free, and str_free are the matching frees.


7. Errors at run time

A check that fails stops the program. It prints runtime error:, what happened, the line and column, and the chain of calls that led there. The exit status is 1.

runtime error: integer overflow: the result of '+' doesn't fit in i32 (at line 4, column 11)
  in total<i32>
  called from the main program, at line 19

halt("message") stops the program the same way, with your own message.

Release builds. --release builds with no runtime checks, as C does: + - * and negation wrap around in the type's width (two's complement), indexes and slices aren't checked, a float converted with as that doesn't fit gives what the processor gives, and division by zero is whatever the processor does (x86: the program stops; ARM: 0). The meaning of every correct program is the same in both kinds of build; only the errors stop being caught. --release-checked keeps every check and leaves out only the chain of calls.


8. Lifetimes

The compiler rejects a pointer or slice that could outlive what it points at. For example:

  • returning &local or a slice of a local array
  • storing a pointer to a local into a global, or into something that lives longer than the local does

It follows pointers through calls. A function returning a pointer is assumed to return part of its pointer arguments, unless the function is marked : new.

For each function, the compiler works out which pointer parameters it keeps, by reading its body: - Read only. It only reads what it was given. Passing a short-lived pointer is then always fine. - Into another parameter. It stores the pointer where another of its parameters points (list_add(list, p)). Then only that argument must live as long. - Anywhere. It stores the pointer into a global, onto a new thread, or through a function value. Then the pointer must outlive everything it could reach.

So save(&global_store, &local_buffer) is accepted when save copies the bytes, and refused when it keeps the pointer.

For memory the compiler cannot see — a raw address, alloc — the programmer is in charge, as in C.


9. Threads and atomics

fn work(arg: *u8): i64
  var counter = arg as *i64
  call atomic_add(counter, 1)
  return 0
endfn

var hits: i64 = 0
var t = thread_start(work, &hits as *u8)
call thread_wait(t)                           // the thread's result
  • thread_start(f, arg) runs f(arg) on a new thread with its own 8 MiB stack, and returns a handle. thread_wait(handle) waits for the thread and gives f's result. Both work on 64-bit Linux and Windows.
  • The atomics work through a pointer to an integer or a pointer, and each is one indivisible step:
Atomic What it does
atomic_load(p) read the value
atomic_store(p, v) write the value
atomic_swap(p, v) write v, and give the old value
atomic_add(p, v) add v, and give the old value
atomic_compare_swap(p, expected, new) write new only if the value is still expected; gives a bool saying whether it did

atomic_fence() orders memory, and cpu_relax() is for spin loops. - threads.sth adds Mutex (mutex_lock, mutex_try_lock, mutex_unlock) and thread_yield(). - Runtime errors on a thread report the thread's own chain of calls.


10. Building for a target

Target Output
linux64, linux32 ELF (with symbols and DWARF line tables, so gdb shows functions and lines)
windows64, windows32 PE .exe
freestanding-x64, freestanding-32 a plain ELF, no OS, no boot code (your loader runs it)
linux-arm64 AArch64 ELF (static, or dynamically linked when it calls C)
android a signed .apk (APK Signature Scheme v2) holding an AArch64 library for NativeActivity; -o x.so gives the library alone

These built-ins are decided while compiling: target_windows(), target_linux(), target_64bit(), target_freestanding(), target_arm64(), target_android() (on Android, target_linux() is true as well). In if target_windows() then ... else ... end, only the branch for the target is built, but both are checked.


11. The gstc command

Command What it does
gstc file.st [-o out] build a program
gstc file.st --check check only
gstc build [dir] build the project described by project.sting
gstc new NAME start a project (sources, a test, project.sting)
gstc test [file.st] run every fn test_...() (each in its own process)
gstc fmt [--check] files lay out source in the standard way (2-space blocks)
gstc doc file.sth Markdown reference from the // comments above each declaration
gstc lsp a language server over stdin/stdout, for editors
--json errors and warnings as JSON lines

A project.sting file looks like this:

name = tool
main = src/main.st
targets = linux64 windows64
output = build
lib = vendor
release = no