StingType

StingType is a programming language made to take beginners who know nothing about programming straight to low-level code — pointers, memory, bits — without the silent, confusing mistakes that make C hard to learn.

gstc (the General StingType Compiler) is a standalone compiler: it turns a .st program straight into a native executable for Linux or Windows (x86, 64- and 32-bit), 64-bit ARM Linux, or a signed, ready to install Android app. It writes the machine code (x86-64, i386 or ARM64) and the executable file itself — it never runs another compiler, assembler, or linker, and it uses no outside code (an Android app needs no Android SDK either). Its runtime (float printing, files, arguments) and its math (MathLib) are StingType, carried inside gstc and compiled like any other program.

print_str("Hello from StingType!")

GSTC 0.95 (prerelease). The language is frozen for 1.0 -- see ROADMAP-1.0.md. New to StingType? Start with TUTORIAL.md.

Quick start

Download gstc (Linux) or gstc.exe (Windows) and run it — nothing else to install:

gstc examples/hello.st -o hello
./hello

On Windows, gstc examples\hello.st -o hello makes hello.exe. Either system can build for the other: --target=windows64 or --target=linux64, and both build 32-bit programs too: --target=linux32, --target=windows32. Programs with no operating system at all (kernels and the like) are --target=freestanding-x64 and --target=freestanding-32. 64-bit ARM Linux (a Raspberry Pi with a 64-bit OS, ARM servers) is --target=linux-arm64, and an Android app is --target=android:

gstc game.st --target=android --assets=assets    # game.apk, signed, ready to install

The binaries package has four compilers: gstc and gstc.exe (64-bit Linux and Windows) and gstc32 and gstc32.exe (for 32-bit Linux and Windows PCs). Each builds programs for its own kind of machine by default, and any of them can build for any target.

Building gstc itself

gstc is written in C, so building gstc from source needs a C compiler, once (programs gstc builds never do):

cc -std=c11 -O2 -o gstc src/*.c

tools/embed_sources.py re-embeds lib/mathlib.sth and src/runtime.sth into gstc after either is edited.

What gstc covers

Everything in the language: integers, floats, bool, strings, structs (laid out byte-for-byte like C's), arrays, slices, pointers, generics, enums, globals, goto, file I/O, stdin, program arguments, the standard library, MathLib, and every safety check, with the full chain of calls in error reports. Only the functions a program actually reaches are compiled into it, so a small program stays small (about 20 KB).

Raw power tools

StingType is low-level on purpose: these need no keyword, no unsafe block, and no special mode. They are simply part of the language, available anywhere — while ordinary code stays checked by default.

// pointer arithmetic, C-style
var p: *u8 = buf + 4
var distance: i64 = p - buf        // in elements, like C

// real Linux system calls -- nothing standing in between
var written: i64 = syscall3(1, 1, str_ptr(msg) as u64, str_len(msg))
var addr: u64 = syscall6(9, 0, 4096, 3, 34, 0, 0) as u64   // mmap

// raw memory, at any width
call poke32(addr, 4000000000)
print_i64(peek32(addr) as i64)

// exact instruction bytes, spliced straight into the program
asm_bytes(0x0F, 0x05)

syscall0–syscall6 are Linux-only; using one while targeting Windows is a compile error, not a silent wrong answer (Windows has no stable syscall numbers). For inline machine code, asm_bytes(...) places exact instruction bytes; asm("...") assembly text isn't available, since gstc carries no assembler.

Low-level tools

No special mode or keyword — these are simply part of the language:

Tool What it does
p + n, p - n, p - q pointer arithmetic, scaled by element size (like C)
addr as *T, p as u64 turn integers into pointers and back
peek8/16/32/64(addr) read memory at any address
poke8/16/32/64(addr, v) write memory at any address
syscall0(nr) … syscall6(nr, a1, …) a direct Linux system call (not available on Windows)
asm_bytes(0x90, …) put these exact machine-code bytes right here
port_in8/16/32(port), port_out8/16/32(port, v) x86 I/O ports (in/out)
str_ptr(s), str_len(s) a string's raw bytes and length
var buf: *u8 = alloc(16) as *u8
call poke32(buf as u64, 305419896)
print_i64(peek8(buf as u64) as i64)      // 120 (0x78, the low byte)
var msg: str = "hello from a raw syscall\n"
call syscall3(1, 1, str_ptr(msg) as u64, str_len(msg))   // write(1, ...)

Options

Option What it does
-o name name of the program to create
--check only look for mistakes — don't build anything
--watch if there are mistakes, wait: fix them and save, and gstc checks again by itself, building once everything is right
--dbg log everything: each step gstc takes (with timings), every warning and error
--target=... native (default), linux64, windows64 (alias windows), linux32, windows32, linux-arm64, android (and the freestanding ones)
--release as fast as it gets, like C: no runtime checks (integers wrap, indexes and casts aren't checked)
--release-checked fast, but every runtime check stays; only the chain of calls is left out of error reports
--base=, --stack= freestanding: where it loads, and its stack -- see Freestanding programs

A tour of the language

// variables: the type is worked out for you, or you can say it
var count = 10
var ratio: f64 = 0.5
const MAX = 100

// functions
fn add(a: i32, b: i32): i32
  return a + b
endfn

// decisions and loops
if count > 5 then
  print_str("big")
elseif count > 0 then
  print_str("small")
else
  print_str("none")
end

for i = 1, 10 do
  print_i32(i)
end

var nums: [4]i32 = [3, 1, 4, 1]
for n in nums do
  print_i32(n)
end

// structs and enums
struct Point
  x: i32
  y: i32
endstruct

enum Direction
  North
  South
endenum

var p: Point = Point(x: 1, y: 2)
var d: Direction = Direction.South

// generics: write it once, use it for any type
fn biggest<T>(a: T, b: T): T
  if a > b then
    return a
  end
  return b
endfn

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

var pair = Pair(first: 1, second: 2.5)   // Pair<i32, f64>

// pointers and bits
var x: i32 = 7
var ptr: *i32 = &x
print_i32(*ptr)
var flags: u8 = 12
print_bool(flags & 4 != 0)               // bitwise ops bind tighter than ==

// the standard library, under a name of your choosing
include "lib/standard.sth" as std
call std.sort(nums)
print_i32(std.max(3, 7))

Types: i8 i16 i32 i64, u8 u16 u32 u64, usize isize (pointer-sized), f32 f64, bool, str, pointers *T, fixed arrays [N]T, slices []T, structs, unions, and enums.

The full rules are in SPEC.md.

More of the language

// choose: one case runs, no falling through; checked while compiling
choose ch
  is 'a' to 'z', 'A' to 'Z' then       // ranges: `to` includes the end,
    kind = 1                           //         `until` stops before it
  is '0' to '9' then
    kind = 2
  else
    kind = 0
end
// (an enum with no `else` must list every variant; a value listed twice
// or overlapping ranges are errors)

// pieces of strings, arrays and slices -- bounds-checked
var word = line[0 until 5]
var middle = nums[2 to 4]

// compound assignment
total += price * count
flags |= 0x80

// characters are numbers: the byte, or the Unicode code point
var c: u8 = 'A'

// layout: unions, packed structs, and sizes known while compiling
union Number
  i: i64
  f: f64
endunion
packed struct Wire
  tag: u8
  size: u32
endstruct
verify sizeof(Wire) == 5, "Wire must match the protocol"   // proven, no code
var at = offsetof(Wire, size)                               // 1
var al = alignof(Number)                                    // 8

// hidden: only this file can use it (libraries' helpers)
hidden fn helper(x: i32): i32
  return x + 1
endfn

// `: new T` -- the result is newly made, never part of the arguments
fn copy_text(s: str): new str
  ...
endfn

stop 3                           // end the program now, exit status 3
halt("config file is missing")   // stop it as a runtime error, with the chain of calls

Arithmetic on two plain numbers (KIB * 64, (1 << 12) - 1) is worked out while compiling, unless it would stop the program — then it is left in, so it still stops with its usual message.

Threads and atomics

fn work(arg: *u8): i64
  call atomic_add(arg as *i64, 1)      // one indivisible step
  return 0
endfn

var hits: i64 = 0
var t = thread_start(work, &hits as *u8)   // its own 8 MiB stack
call thread_wait(t)                         // gives work's result

atomic_load, atomic_store, atomic_swap, atomic_add, atomic_compare_swap, atomic_fence and cpu_relax work on any integer or pointer. threads.sth adds Mutex. The heap is safe to use from every thread. A runtime error on a thread shows that thread's chain of calls, and a thread running out of stack is caught like any other. Threads work on 64-bit Linux and Windows.

The libraries

Each one is built into gstc (no files to ship) and also lives in lib/:

Library What's in it
standard.sth I/O, strings, math on any number type, slices; parse_int<T>, parse_f64, checked_add/sub/mul
collections.sth List<T> (a growable array), Map<V> (text keys), IntMap<V>; map_value/intmap_value give a value or a fallback
text.sth Text (a string builder), fill("@ of @", [a, b]) (or fill2("@ of @", a, b)), split, join, padding, UTF-8
time.sth now_ns, now_ms, unix_time, sleep_ms, Date, date_text
system.sth environment variables, running programs (run, run_output), folders and files
net.sth TCP and UDP over IPv4; host_lookup (names to addresses: DNS, /etc/hosts, the Windows resolver); UPnP (upnp_find, upnp_open_port: a home router forwards a port by itself); http_request
sound.sth WAV files, up to 32 at once, mixed in StingType (ALSA on Linux, waveOut on Windows): play, play_loop, stop_sound, set_volume, update
threads.sth Mutex, mutex_lock/try_lock/unlock, thread_yield
testing.sth expect, expect_int, expect_str, expect_f64 for gstc test
mathlib.sth MathLib (below)
random.sth Rng (xoshiro256**: fast, the same numbers for the same seed on every platform): rng_range, rng_below, rng_f64, rng_chance, rng_normal, rng_shuffle, rng_pick; random_bytes/random_u64/rng_seed_os -- secure randomness from the operating system, for keys and tokens
hash.sth SHA-256 (in one go or piece by piece), HMAC-SHA256, CRC-32, Adler-32, FNV-1a
encoding.sth Base64, Base64url and hex, both ways (decoding refuses anything malformed)
json.sth reading JSON (strict RFC 8259; values by node number, errors with line and column; nesting past 512 refused) and writing it (JsonWriter: commas and escapes taken care of)
regex.sth regular expressions: classes, groups, alternatives, counted and lazy repeats, anchors, \b, (?i) (?m) (?s); regex_find, regex_full, regex_replace_all ($1), regex_split. Never backtracks (a Pike VM): no pattern or text can make it hang
include "collections.sth"
include "text.sth"

var names: List<str>
list_add(&names, "ada")
list_add(&names, "linus")
var ages: Map<i32>
map_set(&ages, "ada", 36)
var line = fill("@ is @ years old", [list_get(&names, 0), "36"])
print_str(line)
str_free(line)

Libraries that use libraries

A library includes what it needs under a name of its own -- json.sth has include "standard.sth" as json_std -- and your program can still include standard.sth as std, or plainly: one file under several names is still one library (its code once, its globals shared), and every name reaches it.

How gstc makes code fast

Two kinds of build. An ordinary build checks everything -- every integer operation, index, slice and cast -- and a failure stops the program with the line and the chain of calls. --release is the C way: no runtime checks at all, so integers wrap around, an index past the end reads whatever is there, and you have full control. Test with ordinary builds; ship with --release (or --release-checked, which keeps the checks and drops only the chain of calls). Either way, gstc makes the code cheaper:

  • Constants folded: a const with a number or bool value becomes that value where it's used, casts of constants are worked out while compiling, and arithmetic or comparisons against a constant use the instruction's immediate form.
  • Division by a constant without the divide instruction (a multiply and a shift), with exactly the divide's results.
  • Small functions inlined, on every target: a function that is a few var x = ... lines and a return (plain numbers, bools or pointers in and out) is put where it's called -- no call, no frame. Error reports still name it: a check that fails inside it says in rotr ... called from compress, at line 12. A constant argument stays a constant inside it (rotr(x, 7) shifts by an immediate 7, and 32 - k is worked out while compiling).
  • Floats in registers (64-bit x86 and ARM64): a float expression of arithmetic, sqrt_f64, negation and values read from variables, fields and elements is worked out in the CPU's float registers directly, each value loaded straight where it's needed. The results are bit for bit the same.
  • Fewer trips through memory: on ARM64 the values waiting inside an expression stay in registers (x23-x27) instead of the frame; on x86-64 the left side of an operator waits in r9-r11 instead of on the stack when the right side can't disturb them; register variables are read straight from their registers; a named array is indexed without a push; field loads take the field's offset in the instruction.

Measured against the previous build (same programs, same output):

Program 64-bit x86 ARM64 32-bit x86
SHA-256 (hash.sth) 1.56 s -> 1.02 s 43% fewer instructions 2.61 s -> 1.69 s
n-body (floats) 30% fewer instructions 34% fewer instructions --
BlockWorld's world generation 9% fewer instructions about 4% faster about 8% faster

Tools

Command What it does
gstc new NAME start a project: project.sting, src/, a test
gstc build [dir] build the project for every target it lists
gstc test [file.st] run each fn test_...() on its own, with what failed and where
gstc fmt [--check] files lay the code out the standard way
gstc doc lib.sth a Markdown reference from the comments
gstc lsp a language server: mistakes shown in your editor as you type

Programs built for Linux carry their function names and a line table (DWARF), so gdb shows crash () at main.st:11 and steps line by line.


When something's wrong

Every error and warning names the file, line and column, shows the line, and highlights exactly what's wrong — and gstc stops before building anything, so a mistake never becomes a program:

test.st:8:26: type error: undeclared variable 'scroe'
     8 | print_f64(average(score, scroe))
       |                          ^~~~~
test.st:3:7: warning: the variable 'unused' is never used
     3 |   var unused: i32 = 5
       |       ^~~~~~

Warnings (an unused variable, code after a return that can never run) don't stop the build. With --watch, gstc keeps the mistakes on screen and waits; save your fix and it checks again by itself.

Mistakes it catches for you

Before your program runs — as a clear error, not a mystery: - values that don't fit their type (var b: u8 = 300) - using the wrong type anywhere (no silent conversions) - comparing things that can't be compared, like two structs, or strings with == (use str_eq) - a pointer or slice that would outlive what it points at — for example returning a pointer to a function's own local variable

While it runs — the program stops with a message saying exactly what and where, instead of carrying on with a wrong answer: - integer overflow, division by zero, out-of-range shifts - reading past the end of an array, slice, or string - running out of stack (usually a function calling itself forever) - using a null pointer

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

Programs give the same output on every platform, down to the last digit of sin(1.0): StingType carries its own math functions instead of relying on each system's.


Windows: frame.sth

lib/frame.sth opens a window and tells you what the user does with it — the same program on Windows and Linux:

include "lib/frame.sth" as frame

if frame.open("My Window", 640, 480) then
  while frame.is_open() do
    frame.wait()                      // or frame.poll() in a game loop
    if frame.key_pressed(frame.KEY_ESCAPE) then
      frame.close()
    end
    if frame.mouse_pressed(frame.MOUSE_LEFT) then
      print_i32(frame.mouse_x())
    end
  end
end
  • open(title, width, height), is_open(), poll() / wait(), close(), set_title(t), resize(w, h), width(), height(), was_resized(), set_fullscreen(on) / toggle_fullscreen()
  • text input: typed_count() and typed_char(i) -- the characters typed this round, with shift and the keyboard layout already applied
  • first-person mouse: set_mouse_capture(true) hides the pointer and keeps it centred; mouse_delta_x() / mouse_delta_y() give the movement; mouse_clicks(b) counts presses
  • mouse_wheel(): notches the wheel turned since last asked (up is positive; 0 where there's no wheel)
  • key_down(k) / key_pressed(k) with frame.KEY_A … KEY_Z, KEY_0 … KEY_9, KEY_ESCAPE, KEY_ENTER, KEY_SPACE, the arrows, KEY_F1 … KEY_F12, Shift/Control/Alt — the same codes everywhere
  • mouse_x(), mouse_y(), mouse_down(b) / mouse_pressed(b) with frame.MOUSE_LEFT, MOUSE_RIGHT, MOUSE_MIDDLE
  • native_window() and native_display(): the window as the system knows it (Windows: HWND and HINSTANCE; Linux: the X11 Window and Display*) — what graphics APIs (Vulkan, DirectX, OpenGL) take to draw into it. frame itself doesn't draw.

On Windows it uses Win32 (user32.dll), on Linux X11 (libX11.so.6). See examples/interactive/window.st.

Building for one system: target_windows() and target_linux()

if target_windows() then ... else ... end compiles only the branch for the system being built for — the other branch is still checked for mistakes, but its code and its library imports are left out, so a Windows program never needs a Linux library (or the other way round).

Shaders: write once, run on every API

A shader is a block in any StingType file, like a struct or a function. gstc compiles it — at build time, by itself — into GLSL (OpenGL), HLSL (Direct3D 11 and 12) and SPIR-V (Vulkan):

shader waves
  uniforms
    time: f32
    tint: vec4
  enduniforms

  vertex(position: vec2, color: vec3)    // the vertex inputs, in order
    varying v_color: vec3                 // handed to the fragment stage
    v_color = color
    return vec4(position, 0.0, 1.0)       // the position
  endvertex

  fragment
    return vec4(v_color, 1.0) * tint      // the pixel's color
  endfragment
endshader

The block becomes a namespace, like an include: waves.source() (for sgi.program) and waves.Uniforms (its uniforms, laid out the way every API agrees on):

if sgi.open(sgi.VULKAN, frame.native_display(), frame.native_window(), w, h) then
  var prog: sgi.Program = sgi.program(waves.source())
  var mesh: sgi.Mesh = sgi.mesh(&verts[0] as *u8, bytes, vertex_count)
  var u: waves.Uniforms
  u.time = 1.5
  u.tint = [1.0, 0.8, 0.6, 1.0]
  while frame.is_open() do
    frame.poll()
    sgi.resize(frame.width(), frame.height())
    if sgi.begin(0.0, 0.0, 0.0, 1.0) then  // start the frame, cleared
      sgi.draw(&prog, &mesh, &u as *u8)
      sgi.present()
    end
  end
end

Change sgi.VULKAN to sgi.OPENGL, sgi.D3D11 or sgi.D3D12 (or use sgi.api_named("d3d12")) and nothing else changes.

  • Types: f32, vec2, vec3, vec4, mat4 (column-major, m * v as in math), bool. Statements: var, assignment, if/elseif/ else, return. Expressions: + - * / (vectors by component, with numbers, mat4 * vec4, mat4 * mat4), comparisons, && || !, swizzles (.xy, .rgb), vec2/vec3/vec4(...), and sin cos tan sqrt abs floor fract exp log min max pow step clamp mix smoothstep dot cross length normalize (a number stands in for a vector: mix(a, b, 0.5)).
  • Depth: a depth line in the block makes its draws depth-tested — nearer things hide farther ones. Depth runs from 0 (near) to 1 (far) in every API (gstc converts it for OpenGL); MathLib's mat4_perspective makes exactly that.
  • Uniforms each take a 16-byte slot (a mat4 four). Each draw gets its own uniform values.
  • Shader mistakes are reported like any other, with the line highlighted.
  • gstc --shader file.sth NAME out writes what shader NAME became: out.vert.glsl, out.frag.glsl, out.hlsl, out.vert.spv, out.frag.spv.
  • A shader block in an included library is used by its own name (glow.source()), whatever the library was included as.
  • Examples: examples/interactive/shaders.st (moving color waves) and cube.st (3D: a spinning, lit, textured cube from an OBJ model).

3D math (MathLib)

math.Mat4 holds a 4x4 matrix as 16 f32s, ready for a shader's mat4 uniform (u.transform = m.m): mat4_identity, mat4_mul(a, b) (b first, then a), mat4_translate, mat4_scale, mat4_rotate_x/_y/_z (radians) and mat4_perspective(fov_y, aspect, near, far) (looking down -Z, depth 0 at near and 1 at far).

Assets: files, pictures and models

embed("file") puts a file's bytes into the program when it's built — as a []u8, with nothing to ship alongside. The path is relative to the file the embed is in; a missing file is an error at build time, with the line shown.

lib/image.sth reads PNG and BMP pictures into RGBA pixels (4 bytes each, the top row first). PNG: every color type (gray, gray + alpha, RGB, RGBA, palette with transparency), bit depths 1 to 16, all row filters, and its DEFLATE compression — all written in StingType. BMP: 24- and 32-bit. (Not yet: interlaced PNGs.)

lib/mesh.sth reads OBJ models: positions, texture coordinates and normals, faces of any number of corners (split into triangles), every corner form (v, v/vt, v//vn, v/vt/vn, negative indices). Each vertex comes out as 8 floats — x, y, z, u, v, nx, ny, nz — ready for sgi.mesh.

Textures in shaders: declare them in the shader block (after the uniforms), sample them in the fragment stage — (0, 0) is the picture's top-left in every API:

texture picture            // smooth (linear) sampling
texture tiles: nearest     // crisp, blocky sampling

fragment
  return sample(picture, v_uv)
endfragment
include "../lib/image.sth" as image

var img: image.Image = image.load(embed("assets/logo.png"))
var tex: sgi.Texture = sgi.texture(img.pixels, img.width, img.height)
image.free(&img)                      // the texture has its own copy
...
if sgi.begin(0.0, 0.0, 0.0, 1.0) then
  sgi.bind_texture(0, &tex)           // the shader's first texture
  sgi.draw(&prog, &mesh, &u as *u8)
  sgi.present()
end

Make textures before a frame starts. Example: examples/interactive/picture.st (an embedded PNG, shown with any API).

Graphics: sgi.sth (the Standard Graphical Interface)

SGI connects a frame window to a real graphics API and hands you that API — the same program on Windows and Linux. It isn't a drawing library of its own; it's StingType's way into the graphics APIs:

API Windows Linux open it with
OpenGL yes (WGL) yes (GLX) sgi.open_gl(frame.native_display(), frame.native_window())
Vulkan yes yes sgi.open_vulkan(frame.native_display(), frame.native_window(), w, h)
Direct3D 12 yes — sgi.open_d3d12(frame.native_window(), w, h)
Direct3D 11 yes — sgi.open_d3d11(frame.native_window())

sgi.present() shows each frame, whichever API is open, and sgi.resize(frame.width(), frame.height()) — cheap enough to call every frame — keeps the API's images the window's size. A short tour:

include "lib/frame.sth" as frame
include "lib/sgi.sth" as sgi

if frame.open("Hello, Vulkan", 640, 480) then
  if sgi.open_vulkan(frame.native_display(), frame.native_window(), 640, 480) then
    while frame.is_open() do
      frame.poll()
      if sgi.vk_begin_frame() then
        sgi.vk_clear(0.1, 0.2, 0.4, 1.0)      // or record your own commands
        sgi.present()
      end
    end
  end
end
  • OpenGL: every function is a value (sgi.glClear, sgi.glBegin, ...); sgi.gl_proc("glCreateShader") gives any other.
  • Vulkan: open_vulkan makes the instance, surface, device, swapchain, command buffer and synchronization; each frame is vk_begin_frame(), your commands, present(). To draw: vk_shader (SPIR-V, compiled ahead of time — e.g. glslangValidator -V), vk_pipeline, vk_buffer, then per frame vk_begin_rendering(r, g, b, a), vk_bind_pipeline, vk_bind_vertex_buffer, vk_draw (Vulkan 1.3's dynamic rendering). Y points up, as in the other APIs. vk_proc(name) gives any Vulkan function.
  • Direct3D 12: the same shape — d3d12_begin_frame(), commands, present(). To draw: d3d12_pipeline (HLSL compiled with sgi.CompileShader(..., "vs_5_0")), d3d12_vertex_buffer, then d3d12_bind, d3d12_bind_vertex_buffer, d3d12_draw.
  • Direct3D 11: the pipeline's methods (sgi.ClearRenderTargetView, sgi.CompileShader, sgi.CreateVertexBuffer, sgi.Draw, ...), and sgi.com_method(object, n) for any COM method.

The same vertices and colors make the same picture in every API. Examples: examples/interactive/triangle_any.st (one triangle, any API: triangle_any vulkan), sgi_apis.st (a color-fading window, any API), triangle.st (a spinning OpenGL triangle), triangle_d3d11.st.

Code for one system goes in if target_windows() then ... else ... end (or target_linux()): gstc compiles only the branch for the target — and nothing after a branch that always returns — so a Windows program never depends on a Linux library, or the other way around.

Functions as values

Through a pointer to a struct, p.field means (*p).field.

A function can be stored, passed and returned like any value. A pointer can be cast to a function type (p as fn(i32): i32) and back, for functions a library hands out by address. Its type is written fn(parameter types): result:

fn twice(x: i32): i32
  return x * 2
endfn

var f: fn(i32): i32 = twice        // named without calling it: its value
print_i32(f(21))                   // 42

struct Button
  on_click: fn(i32): i32           // in struct fields, arrays, parameters...
endstruct

A function value is a real C function pointer, so system libraries can call StingType functions back — C's own qsort sorting with a StingType comparison, or Windows calling a window procedure:

extern "libc.so.6" fn qsort(base: *u8, count: u64, size: u64, compare: fn(*u8, *u8): i32)
call qsort(&nums[0] as *u8, 6, 4, by_value)

A function value's parameters can be anything -- numbers, pointers, structs, arrays, strs, slices (sort_by(people, by_age) with by_age: fn(Person, Person): bool); a struct reaches it as a pointer to a copy, so the callee still gets its own. Its result is a number, bool, pointer or function. A callback handed to C keeps C's kinds (numbers, bools, pointers), since C passes structs its own way. Functions from a namespaced library work as values too (math.sin), and MathLib's bisect, integrate and newton take any function.

Calling system libraries (FFI)

Any function in a Windows DLL or a Linux shared library can be declared with extern and then called like a StingType function:

extern "user32.dll" fn MessageBoxA(window: *u8, text: *u8, title: *u8, kind: u32): i32
extern "libc.so.6" fn getenv(name: *u8): *u8
extern "libm.so.6" fn hypot(a: f64, b: f64): f64

var r: i32 = MessageBoxA(0 as *u8, str_ptr("Hello!"), str_ptr("StingType"), 0)
print_f64(hypot(3.0, 4.0))
  • Parameters and results can be numbers (integers and floats), bool and pointers. Pass a struct as a pointer (&s) — StingType lays out structs exactly like C, so the library reads and writes them directly. Pass text with str_ptr(s): string literals end with a 0 byte, so str_ptr("...") is a ready C string.
  • gstc uses each system's own calling convention (Windows x64, or Linux's System V), floats and more-than-register arguments included.
  • Static libraries: name a .a archive (or a .o file) and gstc links its object code INTO the program, as a C linker does — no .so needed on the system that runs it: extern "libmylib.a" fn checksum(p: *u8, n: u64): u32. Only the members that define what the program uses are pulled in (and what those need). Symbols no archive defines come from the system's C library, as in an ordinary C link; a library that needs nothing from it makes a fully static program. The path is relative to the file the extern is in. Linux for now (x86-64 ELF objects, any code model gcc makes); not yet: thread-local variables, C constructors, Windows .lib.
  • When the StingType name has to differ from the library's (two systems' versions of one function), name the library's symbol with as: extern "libvulkan.so.1" fn vk_gipa(i: *u8, n: *u8): *u8 as "vkGetInstanceProcAddr".
  • On Windows the DLLs go in the .exe's import table. On Linux a program that calls a library becomes dynamically linked (the system loader finds the libraries); one that doesn't stays fully static.
  • If a program uses the C library (libc.so.6, msvcrt.dll), it exits through the C library's exit, so text printf/puts buffered is never lost. (That text is written when the buffer fills or the program ends, so it can appear after print_* output.)

MathLib (STSML)

lib/mathlib.sth is StingType's math library — the original STSML, remade for today's StingType and written from first principles:

include "lib/mathlib.sth" as math
print_f64(math.sin(math.PI / 6.0))
var v = math.vec2_rotate(math.Vec2(x: 1.0, y: 0.0), math.PI / 2.0)
print_bool(math.is_prime(1000000007))

Constants (PI, TAU, E, PHI, SQRT2, ...), rounding and exact remainders, exp/ln/log2/log10/pow, trigonometry in radians and degrees (exact argument reduction for any input, sin(1e22) included), hyperbolics, number theory, interpolation and Bézier curves, statistics on slices, Vec2/Vec3/Mat2/Complex, numerical methods, geometry, and base conversion. The transcendental functions are within 1 unit in the last digit on 98-100% of inputs (pow, exp, ln, atan, cbrt are correctly rounded 93-99% of the time), and give bit-identical results on Linux and Windows. The built-in sin_f64, pow_f64 and friends are MathLib's functions.

Compression: lib/deflate.sth

DEFLATE compression and decompression, the gzip format and CRC-32, all in StingType — what gzip, PNG and zip use:

include "lib/deflate.sth" as deflate
var gz: deflate.Bytes = deflate.gzip(data, size)        // a .gz file's bytes
var back: deflate.Bytes = deflate.gunzip(gz.data, gz.size)
if !back.ok then print_str(back.error) end
deflate.free(&gz)

Its output is standard (gzip reads it), and it reads what gzip writes, checking the CRC-32 and size. examples/stzip.st is a complete gzip-compatible command-line tool built on it: stzip c file file.gz, stzip d file.gz file.

32-bit programs (linux32, windows32)

gstc also builds 32-bit x86 (i386) programs, for 32-bit Linux and 32-bit Windows (and 64-bit systems running 32-bit programs):

gstc hello.st -o hello --target=linux32       # an ELF32 program
gstc hello.st -o hello --target=windows32     # a PE32 .exe

The same program gives the same results: the whole language, the runtime's safety checks and error reports (with the chain of calls), 64-bit integers (done in pairs of 32-bit registers), floats (SSE2), files, arguments, and calls into C libraries. What differs is what differs in C:

  • Pointers are 4 bytes, so structs containing pointers are smaller, laid out by each system's C rules (i64/u64/f64 fields align to 4 bytes on Linux, 8 on Windows).
  • C's pointer-sized types (size_t, long on Linux, WPARAM, LPARAM) are 32 bits. Declare them as usize/isize and the same extern serves both widths (see "Pointer-sized integers" below).
  • 32-bit Windows has two calling conventions for callbacks: cdecl (C's default -- qsort, bsearch) and stdcall (what Windows calls window procedures with). Mark the second kind stdcall fn (see "Calling conventions" below).
  • Raw system call numbers are the platform's own, as in C: 32-bit Linux's write is 4 (64-bit: 1). syscall0..syscall6 use int 0x80.
  • frame.sth and sgi.sth work on all four targets -- every API (OpenGL and Vulkan on linux32; OpenGL, Vulkan, Direct3D 11 and 12 on windows32) draws the same pixels as its 64-bit build. Not yet on 32-bit: static libraries (.a).

32-bit programs are fixed-address executables (the code addresses its data directly), the usual choice for i386.

Pointer-sized integers: usize and isize

usize and isize are as wide as a pointer -- 64 bits in a 64-bit program, 32 in a 32-bit one -- like C's size_t and ptrdiff_t. They exist for talking to C, whose size_t, long (on Linux), WPARAM, LPARAM and X11 Window change width with the pointer:

extern "libc.so.6" fn strlen(s: *u8): usize      // size_t: right on 64-bit AND 32-bit
extern "libc.so.6" fn qsort(base: *u8, n: usize, size: usize, cmp: fn(*u8, *u8): i32)
var addr: usize = p as usize                      // an address, as an integer (C's uintptr_t)

They're integers like any other -- checked arithmetic (a 32-bit usize overflows where a 64-bit one wouldn't, and says so), literal range checks for the target being built -- and, like every StingType type, they never mix with others silently: usize and u64 need an as between them, even on 64-bit, so a program that builds for 64-bit also builds for 32-bit. (The built-ins keep their fixed types: .len, str_len, sizeof and alloc use u64.)

Calling conventions: stdcall and cdecl

C code calls a StingType function (a function value handed to it) with C's usual convention, cdecl. On 32-bit Windows there's a second one, stdcall, which Windows uses for its callbacks -- window procedures, for one. Mark those as C programs do with CALLBACK (__stdcall):

stdcall fn window_proc(hwnd: *u8, msg: u32, wparam: usize, lparam: isize): isize

cdecl fn says the default out loud; either word can also stand before extern, for documentation (calls to C work with both conventions). On 64-bit Windows and on Linux there is only one convention, so the markers change nothing there -- exactly as C compilers ignore __stdcall on them. Outside that position, stdcall and cdecl are ordinary names.

Freestanding programs (no operating system)

Two more targets build programs that run with no operating system at all -- kernels, boot-time tools, firmware-style programs, anything that owns the machine:

gstc kernel.st --target=freestanding-x64      # 64-bit
gstc kernel.st --target=freestanding-32       # 32-bit (i386)

The result is a plain ELF with no OS calls and no boot code of any kind: no Multiboot header, no mode switch, no page tables. The entry point is the program's own first instruction, and the loader is yours -- write your own bootloader, use Limine, or anything that loads an ELF and jumps to its entry. It loads at one fixed address (1 MiB by default, --base=ADDRESS to move it). On x64 every reference is relative to the instruction, so --base can be anywhere, a higher-half kernel too:

gstc kernel.st --target=freestanding-x64 --base=0xffffffff80000000

Your loader loads the segment at its address (clearing the part past the file: the stack) and jumps in -- on x64 already in 64-bit mode with the image mapped, on i386 in 32-bit protected mode. What it hands over: on x64, boot_info() is what it put in rdi and boot_magic() what it put in rsi (a C-style call entry(info, magic)); on i386, eax (magic) and ebx (info). The program sets up its own stack (64 KiB, or --stack=BYTES) and turns on SSE itself (in ring 0), so floats work. tests/freestanding/run_noboot.py is a tiny loader (on an emulated CPU) the test suite runs these programs with. (--no-boot, from older versions, is still accepted: it's simply how every build works now.)

Nothing is automatic. As in C without a C library, the program decides where things go, by defining these functions (only the ones it needs):

Define this And this works
fn system_write(stream: i32, bytes: *u8, count: usize) print_*, and runtime error reports (stream 1 is output, 2 is errors)
fn system_alloc(size: usize): *u8 alloc, str_concat and everything else that allocates (blocks come back zeroed; return 0 when out of memory)
fn system_free(block: *u8) dealloc gives blocks back (without it, they simply aren't)
fn system_exit(code: i32) the end of the program, and runtime errors (code 1)

Without a system_write, using print_str is a compile error saying what to define (and a runtime error's message goes nowhere -- the program still stops). Without a system_exit, or when it returns, the processor stops for good (cli + hlt in ring 0). What needs an operating system -- files, arguments, standard input, syscall0..6, calling into libraries -- is a compile error where it's used; a library's functions only count if the program really calls them, so including lib/standard.sth is fine.

Reaching the hardware:

Tool What it does
port_in8/16/32(port) the in instruction: read a device's I/O port
port_out8/16/32(port, value) the out instruction: write one
peek8..64 / poke8..64 memory-mapped hardware (VGA text memory at 0xB8000, ...)
asm_bytes(0xFA) any instruction without operands (cli 0xFA, sti 0xFB, hlt 0xF4)
boot_magic(), boot_info() what your loader handed over (x64: rsi and rdi; i386: eax and ebx)
target_freestanding() true when building for a freestanding target (decided while compiling, like target_linux())

Everything else is the same language: every safety check, runtime errors with the chain of calls (including stack overflow, on the program's own stack), 64-bit math, floats, structs, strings. examples/freestanding/kernel.st is a small kernel that writes to the screen and the serial port and does some real work, for your loader to start.

Not there yet: interrupt handlers (the iret side), and linking static libraries into a freestanding program.

64-bit ARM Linux (--target=linux-arm64)

gstc hello.st -o hello --target=linux-arm64

An AArch64 ELF program for 64-bit ARM Linux: a Raspberry Pi 3/4/5 with a 64-bit OS, ARM servers and laptops running Linux. gstc writes the ARM64 machine code itself, like everywhere else, and the whole language comes along: every safety check and error report with the chain of calls, floats, threads and atomics (ldxr/stxr), functions as values, C libraries (extern, the AAPCS64 calling convention, callbacks from C), and the libraries (frame.sth, sgi.sth's OpenGL, sound, net, files, time). A program that calls no C library is a static executable; one that does is dynamically linked. On an ARM machine, gstc builds this by default (native).

target_arm64() is true while building for ARM64 (Linux or Android), and raw system calls use ARM64 Linux's own numbers there (write is 64, not 1), as in C.

Android apps (--target=android)

gstc game.st --target=android --assets=assets --app-name="My Game" --package=com.me.game
adb install -r game.apk
adb logcat -s StingType        # what the program prints

gstc makes the whole app itself: the ARM64 code (as a library Android's NativeActivity loads, so the app needs no Java at all), the manifest (in Android's binary XML), the icon's resource table, the package (the library stored uncompressed, 16 KB aligned, and unpacked by Android at install), and the signature (APK Signature Scheme v2, RSA with SHA-256). No Android SDK, Studio, Java or NDK is involved. Android 8.0 (API 26) and up, on 64-bit ARM phones and tablets — practically every one sold since 2017 (Android 11 and later emulators run ARM apps too).

The same program runs on a phone. What changes:

  • frame.sth: open() gets the app's full-screen window (the size asked for is ignored — resize(0, 720) instead draws at 720 pixels tall and lets the screen stretch it, which keeps pixel-laid-out UIs readable on 3K screens). Touch: touch_count(), touch_x(i), touch_y(i), touch_id(i), touch_started(i); the first finger also acts as the mouse (set_touch_mouse(false) turns that off). The Back button is KEY_ESCAPE. show_keyboard(true) asks for Android's on-screen keyboard (letters arrive through typed_char, as on a PC); some phones' keyboards (Samsung's, for one) won't open for a NativeActivity app, so for typing that must work everywhere, draw a keyboard and feed it in with type_char. is_paused() says the app is in the background — the window is gone then, so stop drawing (wait() sleeps until something happens).
  • Virtual input (every system): press_key(key, down), press_button(button, down), type_char(c) and move_mouse_by(dx, dy) act as the keyboard and mouse would, so on-screen controls can drive code written for a keyboard unchanged. touch_not_mouse(x, y, w, h): fingers that come down in that rectangle aren't the mouse, so pressing on-screen controls drawn over a menu doesn't click the menu under them.
  • sgi.sth: OpenGL is OpenGL ES 3 (through EGL); open_gl() gives it. Classic OpenGL (glBegin/glEnd, GL_QUADS, the matrix stack, glColor, textures) is emulated on top of it, so the same drawing code works; shaders written for gl_stage get ES's version line by themselves. Vulkan and Direct3D aren't on Android (open_vulkan says false). open_gles(display, window) asks for GL ES directly, on Linux too.
  • sound.sth plays through AAudio, and goes quiet while the app is in the background.
  • Printing goes to the system log (adb logcat -s StingType), a line at a time; runtime errors (with the chain of calls) go there too, at priority E. When a program stops with an error -- a runtime error, halt(), a crash (a null pointer, an instruction the phone's CPU lacks, an abort) -- the error and what was printed just before it are drawn on the screen until it's tapped, since a phone has no console. A crash reports its signal and the chain of calls, like on a PC.
  • Files: the program starts in the app's private folder, and the folders given with --assets are copied there the first time (and again whenever they change), so read_file("assets/x.png") works as on a PC.
  • net, threads, time, system work as on Linux (with Android's C library where that differs: name lookups, getenv, the shell is /system/bin/sh).
  • target_android() is true for this target (and target_linux() too, since Android is Linux underneath). A C library named with a version (libX11.so.6) or a .dll is refused while building for Android — Android's libraries are plain libname.so — with a hint to keep that call for other systems behind if !target_android() then.
Option What it does
--app-name=NAME shown under the icon (default: the file's name)
--package=com.you.app the app's identity (default org.stingtype.NAME); an update must keep it
--version-code=N, --version-name=1.0 N must go up with each release you install over the last
--icon=FILE.png the icon (default: a StingType one)
--assets=FOLDER copied beside the program, keeping its name (again for more)
--orientation=any\|landscape\|portrait how the screen may turn
--permission=NAME another permission, e.g. VIBRATE (again for more)
--no-internet leave out the INTERNET permission (it's in by default, for net.sth)
--debuggable lets adb shell run-as look inside the app's folder
--key=K.pk8 --cert=C.x509.der sign with this key (unencrypted RSA, PKCS#8 or PKCS#1 DER, and its X.509 certificate)
-o NAME.so only the library, no .apk

Signing. Android installs only signed apps, and an update must be signed by the same key as what it replaces. Without --key, gstc makes an RSA-2048 key the first time and keeps it in ~/.gstc/android/ (%USERPROFILE%\.gstc\android\ on Windows): keep that folder, and every later build installs over the last. For the Play Store, use a key of your own (--key/--cert; Play App Signing works with any).

The standard library

lib/standard.sth — 50 functions, written in StingType itself:

  • Input/output: write, writeln, write_int, write_f64, read_line, read_i32, read_file, write_file, append_file, file_exists
  • Strings: str_eq, str_contains, str_index_of, str_substring, str_trim, str_to_upper, str_from_int, str_parse_i32, and more
  • Math (any number type): min, max, abs, clamp, sign, gcd, power
  • Slices (any element type): sum, avg, min_in, max_in, contains, index_of, count_of, sort, reverse, fill, swap (sort is an introsort: n log n at worst -- a million numbers in well under a second)
  • Numbers from text: parse_int<T> (decimal, 0x, 0b) and parse_f64, which gives the double nearest the exact decimal value (correctly rounded, however many digits)

Built into the language itself: print_i32/print_f64/print_str/..., sqrt_f64, sin_f64, cos_f64, pow_f64, log_f64 and friends, wrap_add/wrap_mul (arithmetic that wraps instead of stopping, for hashes), memory (alloc, dealloc), and files.

Functions that build a new string (like str_to_upper) give you a string you own: call str_free on it when you're done.


Project layout

src/           the compiler: lexer, parser, checker, diag.c (errors and
               warnings with highlighted lines, --dbg), builtins.c (built-in
               signatures), modules.c (includes), native.c (x86-64 machine
               code + the ELF and PE writers), native32.c (i386),
               native_a64.c (ARM64, and Android's library), apk.c (the
               Android package and its signature), runtime.sth (the runtime,
               in StingType), embedded.h (MathLib and the runtime, carried
               inside gstc)
lib/           standard.sth and mathlib.sth (STSML)
examples/      example programs, each with its expected output
tests/         the test suites
tools/         embed_sources.py (re-embeds the .sth files into gstc)
STATUS.md      the full development journal

Run every test with bash run_tests.sh (add --asan to also check for memory errors). The examples are part of the tests: each must still print exactly its .expected output.