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
.stfile. A library is a.sthfile, brought in withinclude. 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(closingrepeat), andendshader. - 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.Redis an enum variant.ns.namereaches into a library includedas 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
returnon every path. The compiler checks this. : new Tmarks a result as newly made (heap memory, a newstr), 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:
- relative to the including file
- in
--lib=DIRfolders - in the
GSTC_LIBfolders - in a
libfolder next to gstc - 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
elsemust 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
&localor 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)runsf(arg)on a new thread with its own 8 MiB stack, and returns a handle.thread_wait(handle)waits for the thread and givesf'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