A statically-typed systems programming language built from scratch in C using a multi-pass compiler architecture.
Pebble compiles to C, providing low-level control with modern language features including modules, optionals, slices, defer statements, and a context-based allocator system inspired by Odin.
make # Build the peb compiler
make clean # Clean build artifacts
make install # Build and install the peb compiler and std lib to your bin/ path# Compile a Pebble source file
./pebc program.peb
# Compile with options
./pebc program.peb -o myapp --warnings
# Generate C without compiling
./pebc program.peb --generate-only --keep-c-file
# Compile in freestanding mode (no stdlib)
./pebc program.peb --freestanding
# Link with libraries
./pebc program.peb -l pthread -L /usr/local/lib -I /usr/local/includeFunctions:
// Regular function
fn add(a int, b int) int {
return a + b;
}
// Expression function (auto-returns)
fn square(x int) int => x * x
// Variadic function
fn sum(...values []int) int {
// Implementation
}
// Generic function
fn map[T, Ret](items []T, operation fn(T) Ret) []Ret {
var result = (mem::new(items.len * sizeof Ret) as *Ret)[:items.len];
loop 0..items.len : i {
result[i] = operation(items[i]);
}
return result;
}Variables:
let name = "hello"; // Immutable
var count = 42; // Mutable
let pi = 3.14; // Type inference
var x i32 = 100; // Explicit typeControl Flow:
fn max(a int, b int) int {
if a > b {
return a;
} else {
return b;
}
}
fn factorial(n int) int {
var result = 1;
while n > 1 {
result = result * n;
n = n - 1;
}
return result;
}
// Range loop (exclusive)
fn print_numbers() void {
loop 0..10 {
print iter; // iter is implicit loop variable
}
}
// Range loop (inclusive)
fn sum_range(start int, end int) int {
var total = 0;
loop start..=end {
total = total + iter;
}
return total;
}
// Range loop with custom iterator name
fn print_with_custom_var() void {
loop 0..5 : i {
print i;
}
}
// For loop
fn print_range(start int, end int) void {
for var i = start; i < end; i++ {
print i;
}
}
// While loop with break/continue
fn search(arr []int, target int) bool {
var i = 0;
while i < arr.len {
if arr[i] == target {
return true;
}
i++;
}
return false;
}int,i8,i16,i32,i64,isize- Signed integersu8,u16,u32,u64,usize- Unsigned integersfloat- 64-bit floating pointbool- Boolean values (true/false)char- Single characterstr- String literals (null-terminated C strings)void- No return value
Pointers:
var x int = 42;
var ptr *int = &x; // Address-of
var value int = *ptr; // DereferenceArrays:
var arr [5]int = [1, 2, 3, 4, 5];
var repeated [10]int = [0; 10]; // [0, 0, 0, ...]
var elem int = arr[2];Slices:
var numbers []int;
var subset = arr[1:4]; // Slice from index 1 to 3
var prefix = arr[:3]; // First 3 elements
var suffix = arr[2:]; // From index 2 to endStructs:
type Point = struct {
x float;
y float;
};
var p Point = Point.{ x = 10.0, y = 20.0 };
var px = p.x;
// Generic structs
type Entry[K, V] = struct {
key K;
value V;
state EntryState;
};
type HashMap[K, V] = struct {
entries *Entry[K, V];
len usize; // number of OCCUPIED entries
cap usize; // number of slots in table
hash_fn fn (K) u64;
eq_fn fn (K, K) bool;
backing Allocator;
};
var names = HashMap.[str, int]{
entries = nil,
len = 0,
cap = 0,
hash_fn = hash::hash_str,
eq_fn = fn (a, b str) bool => libc::strcmp(a, b) == 0,
backing = context.default_allocator,
};Tuples:
var pair (int, str) = (42, "hello");
var triple (int, int, int) = (1, 2, 3);Enums:
type Color = enum {
RED,
GREEN,
BLUE
};
var output_col = Color.RED;Unions:
// Untagged union
type Value = union {
int_val i32;
float_val f32;
bool_val bool;
};
var val = Value.{ int_val = 32 };
var other Value;
other.int_val = 32;
print other.float_val; // Undefined behaviour. Could be garbage data
// Tagged union
type Data = union enum {
Int i32;
Float f32;
Bool bool;
};
var data = Data.{ Int = 42 };
//print data.Float; // Runtime error
print data.Int; // Safe
switch data {
case Int: print `Got integer: {data.Int}`;
case Float: print `Got Float: {data.Float}`;
else: print `Got Bool: {data.Bool}`;
// Can also be case Bool instead of the `else:` case
}Optionals:
var maybe ?int = some 42;
var nothing ?int = none;
// Force unwrap (runtime error if none)
var value int = maybe!;Function Types:
type BinaryOp = fn(int, int) int;
fn apply(op BinaryOp, a int, b int) int {
return op(a, b);
}Switch Statements:
fn classify(n int) str {
switch n {
case 0: return "zero";
case 1: return "one";
case 2: return "two";
else: return "many";
}
}Defer Statement:
fn read_file(path str) void {
var file = io::open(path, io::MODE_READ);
defer io::close(file); // Runs at end of scope
// Use file...
// close() automatically called even if early return
}Deferred statements execute in LIFO order (Last In, First Out):
defer print "third";
defer print "second";
defer print "first";
// Prints: first, second, thirdExplicit Casting:
var x int = 42;
var y float = x as float;
var ptr *void = &x as *void;Sizeof:
var size = sizeof int;
var custom_size = sizeof Point;Bitwise Operators:
var a = 0b1010;
var b = 0b1100;
var and = a & b; // Bitwise AND
var or = a | b; // Bitwise OR
var xor = a ^ b; // Bitwise XOR
var not = ~a; // Bitwise NOT
var shl = a << 2; // Left shift
var shr = a >> 1; // Right shiftIncrement/Decrement:
var i = 0;
i++; // Postfix increment
i--; // Postfix decrementImporting Modules:
// Import from standard library
import "std:io";
import "std:mem";
import "std:string";
// Import from relative path
import "utils/math";
import "data/structures";
fn main() void {
var s = string::new();
string::push_str(s, "Hello");
print string::to_str(s);
string::delete(s);
}Extern Declarations:
// Single extern function
extern fn strlen(s str) usize;
// Extern block
extern {
fn malloc(size usize) *void;
fn free(ptr *void) void;
fn memcpy(dst *void, src *void, n usize) *void;
}
// Extern with library name
extern "libm.so" {
fn sqrt(x float) float;
fn pow(base float, exp float) float;
}
// Extern opaque types
extern {
type FILE;
type pthread_t;
}
// Extern variables and constants
extern {
var errno i32;
let stdin *FILE;
}Calling Conventions:
// C calling convention (for FFI)
fn "c" callback(value int) void {
print value;
}
// Pebble calling convention (default, receives context)
fn allocate_data(size usize) *void {
// `context` is implicitly available
return context.default_allocator.alloc(
context.default_allocator.ptr,
size
);
}Pebble functions use a unique calling convention that passes a context parameter containing an allocator:
fn use_allocator() void {
// context.default_allocator is automatically available
var data = context.default_allocator.alloc(
context.default_allocator.ptr,
1024
);
// Use data...
context.default_allocator.free(
context.default_allocator.ptr,
data
);
}In C, this looks like:
typedef struct {
Allocator default_allocator;
} __pebble_context;
typedef struct Allocator {
void *ptr;
void *(*alloc)(__pebble_context, void *, size_t);
void (*free)(__pebble_context, void *, void *);
} Allocator;The context contains:
default_allocator- An allocator with function pointers forallocandfree
This enables:
- Custom memory allocation strategies
- Dependency injection of resources
- Freestanding/embedded environments (empty context)
Arena allocator for efficient bulk memory allocation and deallocation in slabs:
import "std:mem/arena";
var arena Arena;
arena::init(&arena, 4096, context.default_allocator);
defer arena::destroy(&arena);
var alloc = arena::allocator(&arena);
// Use alloc.alloc and alloc.free for allocations within the arenaFunctional programming utilities for working with slices:
import "std:func";
var numbers = [1, 2, 3, 4, 5];
var doubled = func::map(numbers, fn(x int) int => x * 2);
var evens = func::filter(numbers, fn(x int) bool => x % 2 == 0);
var sum = func::reduce(numbers, 0, fn(acc int, x int) int => acc + x);Hash functions for various data types (e.g., strings via FNV-1a, integers via splitmix64):
import "std:hash";
var str_hash = hash::hash_str("hello");
var int_hash = hash::hash_int(42);
var bytes_hash = hash::hash_bytes(&data, len);Generic hash map data structure for key-value storage:
import "std:hmap";
var map = hmap::new(hash::hash_str, fn(a str, b str) bool => libc::strcmp(a, b) == 0);
defer hmap::delete(&map);
hmap::insert(&map, "key", 42);
var value = hmap::get(&map, "key");File I/O operations:
import "std:io";
// File operations
var file = io::open("data.txt", io::MODE_READ);
defer io::close(file);
var content = io::read_all(file);
var line = io::read_line(file);
// Path utilities
if io::exists("config.json") {
print "Config found";
}
var is_directory = io::is_dir("/tmp");
// Directory operations
io::makedir("output");
io::delete("old.txt");
io::rename_path("old.txt", "new.txt");Bindings to C standard library functions:
import "std:libc";
var len = libc::strlen("hello");
var copy = libc::strcpy(dest, "source");
libc::free(libc::malloc(1024));Mathematical utilities and bindings to C math library functions:
import "std:math";
var dist = math::sqrt(25.0); // 5.0
var sine = math::sin(math::PI / 2); // 1.0
var power = math::pow(2.0, 3.0); // 8.0
var max_val = math::max(10, 20); // 20
var clamped = math::clamp(15, 0, 10); // 10Memory allocation utilities:
import "std:mem";
var ptr = mem::new(1024); // Allocate 1024 bytes
defer mem::delete(ptr); // Free memory
var aligned = mem::align_up(size, 16); // Align to 16 bytesGeneric set data structure for unique key storage:
import "std:set";
var set = set::new(hash::hash_str, fn(a str, b str) bool => libc::strcmp(a, b) == 0);
defer set::delete(&set);
set::insert(&set, "item");
if set::contains(&set, "item") {
print "Found";
}Growable string type:
import "std:string";
var s = string::new();
defer string::delete(s);
string::push_char(s, 'H');
string::push_str(s, "ello");
print string::to_str(s);Dynamic array (vector) type for resizable sequences:
import "std:vec";
var v = vec::new.[int]();
defer vec::delete(&v);
vec::push(&v, 10);
vec::push(&v, 20);
var first = vec::get(&v, 0);
vec::remove(&v, 0); // Remove element-v, --verbose Enable verbose output
-w, --warnings Enable C compiler warnings
--keep-c Keep generated C file (default)
--no-keep-c Remove generated C file after compilation
--generate-only Only generate C source without compiling
--compiler <compiler> Specify C compiler (autodetects gcc/clang/cc)
-o <name> Output executable name (default: output)
-c <name> Output C file name (default: output.c)
--check-only Verify the program without generating any source-l <library> Link library (e.g., -l pthread)
-L <path> Add library search path
-I <path> Add include search path
--header <name> Include local header in source
--sys-header <name> Include system header in source
--cc-flags <flags> Pass flags directly to the C compiler--std-path <path> Location of std library (default: alongside compiler)--freestanding Generate freestanding code (no standard library)
--entry-point <symbol> Entry point of your code (default: main)
--no-main No entry point. Compiles to object file only
--shared Compile as a shared library--debug Compile in debug mode
--release-small Compile for a smaller binary
--release-safe Compile with runtime safety features
--release Compile with standard release compiler flags# Compile with pthread
./pebc server.peb -l pthread -o server
# Generate shared library
./pebc mylib.peb --shared -o libmylib
# Freestanding for embedded
./pebc kernel.peb --freestanding --entry-point kernel_main
# Custom C compiler with includes
./pebc app.peb --compiler clang -I ./include -L ./lib -l mylib
# Keep C file for debugging
./pebc program.peb --keep-c --generate-only
# Object file without entry point
./pebc module.peb --no-main -o module.oPebble compiles to C:
- Generates type-safe C code with proper struct declarations
- Handles type dependencies with topological sorting
- Emits context plumbing for Pebble calling convention
- Invokes C compiler (clang/gcc/cc) for final compilation
- Simplicity: Clean syntax inspired by Go, Rust, and C
- Safety: Static typing with optional types and compile-time checks
- Control: Direct memory management with modern conveniences (defer, context)
- Interoperability: Seamless C FFI for systems programming
- Modularity: First-class module system with standard library
- Flexibility: Multiple build modes from freestanding to full stdlib
- Memory Management: Arena allocator for compiler, user code manages memory explicitly
- Calling Conventions: Pebble convention injects context, C convention for FFI
- Type System: Structural typing with nominal type aliases
- Error Handling: Optionals and explicit error codes (no exceptions)
- Compilation Speed: Fast single-pass C generation after type checking