LLVM-first

Skunk Language Reference

Skunk is a human-designed, AI-implemented experimental programming language. This reference covers the implemented language surface in the current compiler/runtime.

Project Shape

Skunk is a language-design project and compiler playground targeting LLVM.

Safety Note

Skunk is experimental and should not be used for critical, safety-sensitive, or high-reliability software.

Status

Skunk uses one execution path: native compilation through LLVM and clang.

  • Source-level syntax is defined in src/grammar.pest.
  • Implemented behavior is backed by parser, type-checker, source-loader, and compiler tests.
  • This page documents the currently implemented language surface rather than future ideas.

Compiler Notebook

If you are new to compilers or new to LLVM, the repository now includes a slower, beginner-oriented guide to how Skunk is built.

Install

Skunk runs on macOS and Linux. It needs clang to link native executables: on macOS run xcode-select --install; on Linux install clang with your package manager.

Install the latest release with one command:

curl -fsSL https://dmgcodevil.github.io/skunk/install.sh | sh

The script detects your OS and architecture, downloads the prebuilt binary from GitHub Releases, verifies its checksum, and installs it to ~/.skunk/bin. Set SKUNK_VERSION=v0.1.0 before the command to pin a specific version.

Alternatively, download a tarball from the releases page manually, or build from source with Rust:

cargo install --git https://github.com/dmgcodevil/skunk

Verify the installation:

skunk --help

Toolchain

Build Skunk with Rust, then compile a Skunk entry file into a native executable.

cargo build
cargo run -- compile path/to/main.skunk ./out
./out

Compile and run through a temporary native executable with either command:

cargo run -- path/to/main.skunk
cargo run -- run path/to/main.skunk

Building an App

Scaffold a project with skunk new. It creates a manifest, an entry file with a hello-world main, and a starter test:

skunk new demo
cd demo
skunk build      # compiles src/main.skunk into target/demo
./target/demo

The project layout:

demo/
  skunk.toml       # project manifest
  src/main.skunk   # entry point
  target/          # build output (gitignored)

skunk.toml configures the build. libraries adds -l linker flags and frameworks adds macOS frameworks, which is how programs using extern "C" link against native libraries:

[package]
name = "demo"
entry = "src/main.skunk"

[build]
optimize = true
libraries = ["sqlite3"]
frameworks = ["Cocoa"]

The manifest format is intentionally small. Settings unknown to the current compiler are ignored for forward compatibility and reported as warnings, so misspellings do not disappear silently.

Single files still work without a project: skunk run file.skunk and skunk compile file.skunk out are unchanged.

C Interop

Skunk can import functions from native libraries with an extern "C" declaration. The declared name is emitted as an unmangled C symbol and can be called like an ordinary Skunk function:

extern "C" function cos(value: double): double;
extern "C" function strlen(value: string): long;

function main(): void {
    print(cos(0.0));
    print(strlen("skunk"));
}

The first interop version deliberately exposes a small ABI-safe surface:

Skunk type C boundary representation
byte, short, int, long Signed 8-, 16-, 32-, and 64-bit integers
float, double 32- and 64-bit floating-point values
bool / boolean C-compatible boolean value
string C string pointer; ownership and lifetime remain part of the native API contract
*T Raw pointer; aggregates may be passed behind pointers
void Allowed only as a return type

Native libraries are selected through a project manifest. Each libraries entry becomes -l<name>; each frameworks entry becomes -framework <name> on macOS. Both skunk build and project-level skunk test use these settings.

[build]
libraries = ["sqlite3"]
frameworks = ["Cocoa"]
  • Skunk currently imports C functions; exporting Skunk functions with a C ABI is not implemented.
  • Headers are not parsed. Declarations must be written explicitly and must match the native signature.
  • Variadic functions, link-name aliases, callbacks, and aggregates passed by value are not supported yet.
  • Safe references, slices, arrays, structs, enums, function values, traits, unions, and intersections cannot cross the C ABI directly.
  • Passing pointers or strings to native code follows the native library's safety, lifetime, and ownership rules.

Standard Library

The standard library ships embedded inside the compiler binary and is materialized on first use, so an installed skunk needs no separate SDK download. The std. import prefix is reserved: it always resolves into the SDK, never into project files.

import std.math;

function main(): void {
    print(sqrt(16.0));
    print(pow(2.0, 10.0));
    print(max(3, 9));
}

std.math provides double-precision functions bound to libm through extern "C" (sqrt, cbrt, pow, exp, log, log2, log10, sin, cos, tan, asin, acos, atan, atan2, floor, ceil, round, fabs, fmod) and integer helpers written in Skunk (abs, min, max, clamp). The SDK and compiler are one artifact and share one version. See examples/calculator for a project that combines a custom module with std.math.

Native Tests

Tests are declared next to the code they cover with test blocks. Assertions go through the Testing API:

struct Point {
    x: int;
    y: int;
}

test "struct shorthand" {
    x: int = 3;
    point: Point = Point { x, y: 4 };

    Testing::expect(point.x == 3);
    Testing::expect_eq(4, point.y);
}

Run tests with the test command. In a project directory, skunk test uses the manifest entry; a file path or a name filter can be given explicitly:

skunk test
skunk test path/to/module.skunk
skunk test --filter shorthand

The runner compiles tests natively: each test block becomes a function, a generated main drives them, and the process exits non-zero if any test fails. Output reports each test with its elapsed time:

PASS addition (0.01 ms)
FAIL struct shorthand (0.02 ms)

2 tests, 1 passed, 1 failed

Available assertions: Testing::expect(condition), Testing::expect_eq(expected, actual), and Testing::fail(). In this first version, expect_eq compares int values; use expect for other types. Filters are case-sensitive substring matches. A failed assertion marks the test failed but keeps executing, so one run reports every failing assertion. A process crash still stops the remaining tests because tests currently share one native process. Test blocks are ignored by skunk run, skunk compile, and skunk build — they only compile during skunk test.

Programs and Modules

Skunk supports multi-file programs through module, import, and export.

module app.math;

function helper(n: int): int {
    return n + 1;
}

export function inc(n: int): int {
    return helper(n);
}
import app.math;

function main(): void {
    print(inc(41));
}
  • import app.math; resolves to app/math.skunk relative to the entry file directory.
  • Imported files must declare the matching module name.
  • If a module uses export, only exported top-level declarations are visible to importers.
  • If a module uses no export, current behavior stays all-public for compatibility.

Types

Skunk currently supports primitive types, fixed arrays, slices, safe references, raw pointers, function types, structs, enums, generic instantiations, transparent type aliases, union types, and trait intersection types.

Form Meaning
byte, short, int, long Signed integer primitives
float, double Floating-point primitives
boolean, char, string, void Core built-in types
[N]T Fixed-size value array
[]T Slice view over contiguous elements
&T, &mut T Safe shared or mutable reference to one value
*T Raw pointer to one value for unsafe memory operations
*const T, []const T Read-only pointer or slice view
(A, B) -> C Function type
Box[int] Generic type instantiation
A | B Union type accepting a value of either member type
Readable & Writable Runtime trait intersection requiring every listed trait
type Name = T; Transparent type alias; aliases do not create a distinct nominal type
type UserId = int;
type TextOrNumber = string | int;
type ReadWrite = Readable & Writable;
type Either[T] = T | string;
type ComparableValue[T: Serializable & Comparable] = T;

export type PublicId = UserId;
  • Aliases may be concrete or generic, may declare capability or subtype bounds, and may be exported from modules.
  • Alias expansion is transparent and recursive alias cycles are rejected.
  • Intersections are currently restricted to traits. A concrete value must conform to every member trait.
  • & binds more tightly than |; parentheses may be used to make composed types explicit.

Allocator and Arena are built-in runtime types used for explicit memory management.

Bindings and Const

Skunk distinguishes between const bindings and const views.

const answer: int = 42;

function copy_into(const dst: []int, src: []const int): void {
    for (i: int = 0; i < src.len; i = i + 1) {
        dst[i] = src[i];
    }
}
  • const name: T makes the binding non-reassignable.
  • []const T and *const T make the viewed elements or pointee read-only.
  • []T is assignable to []const T, but not the other way around.
  • const dst: []int still allows dst[i] = ... because the binding is const, not the slice contents.

Functions and Control Flow

Skunk supports named functions, lambdas, closures, if, for, return, defer, and block scoping.

function add(a: int, b: int): int {
    return a + b;
}

function main(): void {
    arena: Arena = Arena::init(System::allocator());
    defer arena.deinit();

    total: int = add(5, 7);

    if (total > 10) {
        print(total);
    }

    counter: () -> int = function(): int {
        total = total + 1;
        return total;
    };

    print(counter());
}

Closures can capture and mutate surrounding locals. Function values can be stored, returned, and passed as arguments.

defer expression; schedules an expression for the end of the current lexical scope. Deferred expressions run in last-in, first-out order on both normal scope exit and return; their values and arguments are evaluated when the scope exits.

Arrays and Slices

Fixed arrays use value semantics. Slices are views over contiguous storage.

a: [4]int;
b: [4]int = [4]int::fill(7);
c: [4]int = [1, 2, 3, 4];

mid: []const int = c[1:3];

print(a[0]);
print(b.len);
print(mid[0]);
  • [N]T without an initializer is zero-initialized.
  • [N]T::fill(value) fills every element with the given value.
  • Slices support indexing, .len, and range slicing with omitted bounds.
  • Fixed-array and slice indexing is checked at runtime. A negative index or an index greater than or equal to .len stops the program with a panic such as panic: index 4 out of bounds for length 4.
  • Slice ranges require 0 <= start <= end <= len; invalid ranges also panic.
  • Collection implementations whose logical length differs from their backing slice can use Bounds::check(index, length) before accessing storage.
  • Fixed arrays can be passed and returned by value.

Structs and Attached Behavior

Structs are data-only product types. Behavior lives in separate attach blocks.

struct Counter {
    const seed: int;
    value: int;
}

attach Counter {
    function new(seed: int, value: int): Counter {
        return Counter { seed, value };
    }

    function bump(mut self): void {
        self.value = self.value + 1;
    }

    function get(self): int {
        return self.value;
    }
}

function main(): void {
    counter: Counter = Counter::new(1, 4);
    counter.bump();
    print(counter.seed);
    print(counter.get());
}

Struct literals support field shorthand when a field name matches an in-scope binding. Counter { seed, value } is equivalent to Counter { seed: seed, value: value }. Shorthand and explicit fields may be mixed:

seed: int = 3;
value: int = 4;

counter: Counter = Counter { seed, value };
next: Counter = Counter { seed, value: value + 1 };

Receiver mutability is explicit:

  • attach Type { ... } adds inherent methods to a type without declaring trait conformance.
  • Attached functions without self are called with Type::name(...) and work well for constructors and factories.
  • A shorthand field such as x uses the same value as x: x; use the explicit form for a different binding or a computed expression.
  • Struct fields may be declared const; they may be initialized but not reassigned later.
  • self means the method may read but may not mutate receiver state.
  • mut self means the method may mutate receiver state.
  • *const T may call only read-only self methods.

Pointers, Allocators, and Arenas

Skunk uses explicit allocation. Plain values use value semantics; safe borrows use &T and &mut T; allocator-backed single objects use *T; allocator-backed buffers use []T.

struct Point {
    x: int;
    y: int;
}

function make_point(alloc: Allocator): *Point {
    point: *Point = Point::create(alloc);
    point.x = 3;
    point.y = 4;
    return point;
}

function main(): void {
    system_alloc: Allocator = System::allocator();
    arena: Arena = Arena::init(system_alloc);
    arena_alloc: Allocator = arena.allocator();

    point: *Point = make_point(arena_alloc);
    values: []int = []int::alloc(arena_alloc, 8);

    print(point.x + values.len);

    arena.deinit();
}
  • System::allocator() returns the system allocator handle.
  • T::create(alloc) allocates one object and returns *T.
  • []T::alloc(alloc, len) allocates a slice buffer.
  • alloc.destroy(ptr) releases a pointer allocation.
  • alloc.free(slice) releases a slice allocation.
  • Arena::init(backing), arena.allocator(), arena.reset(), and arena.deinit() provide arena-style lifetime management.
  • &T shares read-only access and &mut T grants checked mutable access without entering unsafe.
  • Field access like point.x and method calls like point.bump() still auto-deref ordinary typed pointers.

Unsafe Memory

Skunk now has a small unsafe memory layer for low-level pointer work. These operations must appear inside an unsafe { ... } block.

function main(): void {
    value: int = 41;
    other: int = 0;

    unsafe {
        ptr: *int = &value;
        print(ptr.*);
        ptr.* = 42;

        bytes: [4]byte;
        second: *byte = *byte::offset(&bytes[0], 1);
        second.* = 9;

        Memory::set(&bytes[0], 7, 4);
        Memory::copy(*byte::cast(&other), *byte::cast(&value), int::size_of());
    }

    print(int::size_of());
    print(int::align_of());
}
  • unsafe { ... } enables low-level operations the compiler cannot verify as memory-safe.
  • &expr and &mut expr create safe references by default.
  • When a raw pointer is explicitly expected, the same address-of syntax feeds unsafe pointer operations such as *T::cast, *byte::offset, and Memory::copy.
  • ptr.* explicitly dereferences a pointer value.
  • T::size_of() and T::align_of() are safe compile-time layout queries.
  • *T::cast(ptr) reinterprets one pointer type as another pointer type.
  • *byte::offset(ptr, n) performs byte-wise pointer offsetting.
  • Memory::copy(dst, src, count) and Memory::set(dst, value, count) operate on raw bytes.
  • Raw pointer dereferences and offsets remain unchecked; runtime bounds checks apply to fixed arrays and slices.

Windowed 2D

Skunk now includes a small window/input/drawing runtime for simple 2D programs. The current implementation is macOS-first and is aimed at rectangle-based games and visual prototypes.

function main(): void {
    window: Window = Window::create(800, 600, "Skunk");

    for (; window.is_open(); ) {
        window.poll();

        if (Keyboard::is_down(window, 'q')) {
            window.close();
        }

        window.clear(Color::rgb(8, 12, 24));
        window.draw_rect(120.0, 140.0, 96.0, 64.0, Color::white());
        window.present();
    }

    window.deinit();
}
  • Window::create(width, height, title) creates a native window handle.
  • window.poll() pumps OS events so keyboard and close state stay current.
  • window.is_open(), window.close(), and window.deinit() control the window lifetime.
  • window.clear(color) fills the framebuffer and window.draw_rect(x, y, w, h, color) draws clipped solid rectangles.
  • window.present() shows the current frame and updates window.delta_time().
  • Keyboard::is_down(window, 'w') currently uses character keys; arrow keys and richer input enums can come later.
  • Color::rgb(r, g, b), Color::rgba(r, g, b, a), and constants like Color::white() pack colors for drawing.
  • The repository now includes examples/pong.skunk as a complete example built on this API.

Generics

Skunk supports generic structs, functions, enums, traits, and type aliases through monomorphization. Type parameters may have capability constraints, upper subtype bounds, lower subtype bounds, or a combination.

struct Box[T] {
    value: T;
}

function wrap[T](value: T): Box[T] {
    return Box[T] { value: value };
}

function accept[T <: Animal](value: T): T {
    return value;
}

function widen[A, B >: A <: Animal](left: A, right: B): B {
    return left;
}

function widen_where[A, B](left: A, right: B): B
where B >: A, B <: Animal {
    return left;
}
  • Nested instantiations such as Box[Box[int]] are supported.
  • Function type argument inference works from call arguments in common cases.
  • Generic enum constructors infer type arguments from their payloads and expected return, variable, assignment, field, array-element, or concrete parameter type. For example, a function returning Option[int] may write Option::Some(7).
  • Explicit function call type arguments are supported with forms like id[int](42).
  • T <: Upper requires the chosen type for T to be a subtype of Upper. Trait conformance and inherited traits participate in this check.
  • T >: Lower requires the chosen type for T to be a supertype of Lower. When both bounds are present, write the lower bound first: T >: Lower <: Upper.
  • Bounds may move to a where clause and may be split into separate predicates, as in where T >: Dog, T <: Animal.
  • Direct argument evidence is widened to the smallest available union. For example, two direct T parameters receiving Dog and Cat infer T as Cat | Dog. Nested generic types remain invariant.
  • Explicit type arguments are checked exactly and are not widened automatically.
  • Numeric widening remains a value conversion, not a subtype relationship; for example, int does not satisfy T <: long.

Enums and Match

Skunk supports generic enums with unit variants, tuple-style payload variants, attached behavior, and exhaustive enum-focused match.

enum Option[T] {
    None;
    Some(T);
}

attach[T] Option[T] {
    function is_some(self): bool {
        match (self) {
            case None: {
                return false;
            }
            case Some(value): {
                return true;
            }
        }
    }

    function unwrap_or(self, fallback: T): T {
        match (self) {
            case None: {
                return fallback;
            }
            case Some(value): {
                return value;
            }
        }
    }
}

function main(): void {
    some: Option[int] = Option::Some(7);
    none: Option[int] = Option::None();
    print(some.is_some());
    print(none.unwrap_or(42));
}
  • attach Enum { ... } and attach[T] Enum[T] { ... } add instance methods and static functions to concrete and generic enums.
  • When context determines the complete generic type, construct variants concisely with forms like Option::None() and Option::Some(7).
  • Explicit forms such as Option[int]::Some(7) remain available when inference is ambiguous.
  • Variants may carry multiple payload values, such as Pair(A, B).
  • match is exhaustiveness-checked for enums.
  • Attached functions may use the enum's type parameters. Methods that declare their own additional type parameters are not yet supported.

Traits, Conform, and Shapes

Traits work both as generic constraints and as runtime interface values. Traits may extend other traits, and shapes provide reusable structural bounds.

trait Readable {
    function value(self): int;
}

trait Writer: Readable {
    function write(mut self, value: int): int;

    function write_twice(mut self, value: int): int {
        self.write(value);
        return self.write(value);
    }
}

trait Resettable {
    function reset(mut self): void;
}

shape WriterLike {
    function write(mut self, value: int): int;
}

struct Counter {
    value: int;
}

conform Writer for Counter {
    function value(self): int {
        return self.value;
    }

    function write(mut self, value: int): int {
        self.value = self.value + value;
        return self.value;
    }
}

conform Resettable for Counter {
    function reset(mut self): void {
        self.value = 0;
    }
}

function use_counter[T: Writer & Resettable](counter: *T): int {
    counter.reset();
    return counter.write(41);
}

function save[T](value: T): T
where T: Writer & Resettable {
    return value;
}

function use_writer_like[T: WriterLike](writer: *T): int {
    return writer.write(5);
}
function main(): void {
    writer: Writer = Counter { value: 1 };
    print(writer.write_twice(4));
}
trait Cell[T] {
    function get(self): T;
    function set(mut self, value: T): void;
}

struct Box[T] {
    value: T;
}

conform Cell[T] for Box[T] {
    function get(self): T {
        return self.value;
    }

    function set(mut self, value: T): void {
        self.value = value;
    }
}

function main(): void {
    cell: Cell[int] = Box[int] { value: 7 };
    cell.set(42);
    print(cell.get());
}
  • Trait conformance is explicit through conform Trait for Type { ... }.
  • Traits may declare type parameters and bounds, for example trait Cell[T] or trait Cell[T: Serializable]. Each concrete use such as Cell[int] receives a specialized runtime trait layout and vtable.
  • Traits may extend other traits with trait Writer: Readable { ... }; implementing the child trait also satisfies the parent traits.
  • Non-generic traits may provide default method bodies, and conform blocks only need to implement the required methods they want to customize. Generic trait default methods are not supported yet.
  • Shapes provide structural bounds, for example T: WriterLike, without introducing a runtime trait value.
  • Conform targets may be concrete or generic. In conform Cell[T] for Box[T] { ... }, the conformance binder T is inferred from Box[T]. The older explicit form conform[T] Cell[T] for Box[T] remains accepted.
  • Receiver mutability is part of the trait contract: a method declared with mut self may mutate through the receiver, so its conformance method must also declare mut self.
  • Generic bounds can stay inline with function save[T: Writer](...) or move into a where clause for longer signatures.
  • Bounds intersect with &, for example where T: Writer & Resettable.
  • Capability bounds use :; subtype bounds use <: and >:. They may be combined, with a where clause usually giving the clearest result.
  • Trait names may be used as runtime types, such as writer: Writer.
  • Assigning an addressable concrete value to a trait value borrows its storage for dynamic dispatch; rvalues still box a runtime value with a vtable.

Patterns and Destructuring

Skunk currently supports enum patterns in match, struct patterns in match, and standalone struct destructuring statements.

struct Point {
    x: int;
    y: int;
}

function sum(point: Point): int {
    match (point) {
        case Point { x, y }: {
            return x + y;
        }
    }
}

function main(): void {
    point: Point = Point { x: 3, y: 4 };
    Point { x, y: py } = point;
    print(sum(point));
    print(x + py);
}
  • Struct field bindings may use aliases such as y: py.
  • Destructuring statements introduce local bindings in the current scope.
  • Struct pattern matching is exact-type and currently supports one case in V1.

Current Limitations

  • No user-defined allocators yet. The current allocator and arena model is still runtime-provided.
  • The unsafe memory layer is intentionally small today: there are no raw pointer trait bounds, no arbitrary pointer arithmetic beyond *byte::offset, and no general unsafe standard library yet.
  • Struct match is intentionally narrow in this first pass.
  • Type-level match expressions are not part of the current alias V1. Union values can be stored, passed, and returned, but runtime union narrowing and union pattern matching are not implemented yet.
  • Runtime trait values still use a simple V1 representation. Lvalues reuse their existing storage, while temporaries and other rvalues are boxed when converted to trait values.

Design Notes

The language reference should stay focused on implemented behavior. For deeper design context, see the supporting notes in this repository.