.grl is a systems programming language with no garbage collector. It uses regions with owners, generational handles, actors, and capabilities. It runs hosted on Windows, Linux, and macOS from v0.1; Sografe-native later.
Install = copy. One folder, zero installers. Copy it wherever you want, add it to your PATH, and you're done.
# Linux/macOS
cp -r grl-sdk-*/ ~/grl/
export PATH="$HOME/grl:$PATH"
# Windows
:: copy the folder somewhere, add to PATH in System Settings
Verify:
grl --version
Your first program:
print("hello, grl")
Save as hello.grl, then:
grl run hello.grl
| Command | What it does |
|---|---|
grl run <file.grl> |
Compiles and executes (JIT) |
grl check <file.grl> |
Parse + typecheck — prints "ok" or first error |
grl build <file.grl> |
Parse + check + lower — writes <file>.grl.ir |
grl fmt <file.grl> |
Formatter (2 spaces, = alignment, idempotent) |
grl lsp |
Language Server (stdin/stdout, for editor integration) |
grl --version |
Version and toolchain info |
grl lsp speaks the standard Language Server Protocol over stdio. Any
LSP-capable editor (VS Code, Neovim, Helix, etc.) gets:
.)VS Code extension configuration (.vscode/settings.json):
{
"grl.languageServer.path": "/path/to/grl",
"[grl]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "grl"
}
}
grl # the compiler binary (or grl.exe on Windows)
lib/ # stdlib source (empty in v0.1 — stdlib is builtin)
docs/ # language documentation (you are here)
smoke.grl # quick smoke test
smoke-divzero.grl # division-by-zero test (should error, not crash)
run-smoke.sh # runs the smoke test
grl_ir_v1)lib/)This guide walks through .grl's syntax and semantics with examples. It is
not a formal specification — the EBNF (docs/EBNF.md) is the conformance
contract. This is the readable tour.
print("hello, grl")
That's a complete program. No main, no imports, no boilerplate. The
file-level expressions run top to bottom.
# single expression — no block needed
sum(a, b) = a + b
# block body — last expression is the return value
total(values) =
t = 0
for v in values:
t += v
t
# with return type annotation
dist(a: Point, b: Point) -> Float =
dx = a.x - b.x
dy = a.y - b.y
math.sqrt(dx*dx + dy*dy)
# early return
open(path) -> Res[Str] =
if not fs.exists(path):
return err("not found: " + path)
ok(fs.read(path))
Rules:
- = opens a definition body; : opens a control-flow body.
- No end, no {}. Indentation defines blocks (like Python).
- Last expression in a block is the return value.
- return exits early.
- One-line bodies are forbidden — if cond: stmt does not parse.
# declaration by first use
x = 5 # Int (64-bit)
y: Int = 5 # explicit type annotation
name = "grl" # Str
flag = true # Bool
items = [1, 2, 3] # List[Int]
pi = 3.14 # Float (64-bit)
mask = 0b0000_1111 # Int, binary literal
port = 0xFF # Int, hex literal
count = 1_000_000 # Int, underscore separator
Types are inferred. An annotation is optional and acts as a compile-time assertion — if the inferred type doesn't match, it's a compile error.
a = 5u8 # 8-bit unsigned
b = 3u16 # 16-bit unsigned
c = 10u32 # 32-bit unsigned
d = 100u64 # 64-bit unsigned
Without a suffix, integer literals are Int (64-bit signed). Float is
always 64-bit — there is no float suffix.
Naming is a compile error, not a style warning:
| Convention | Used for | Example |
|---|---|---|
| PascalCase | Types | Point, FrameBuffer |
| snake_case | Functions, variables, parameters | read_all, total_count |
| UPPER_SNAKE | Constants | MAX_SIZE, BUFFER_LEN |
Wrong casing = compile error. The formatter won't fix it — you fix it.
api(req: http.Request) -> http.Response =
if req.path == "/photos":
http.response(200, json.write(photos))
else if req.path == "/health":
http.response(200, "ok")
else:
http.response(404, "nothing")
An if with else can be the last expression in a block — its value is
the taken branch's value. An if without else is a statement only.
# simple iteration
for v in values:
print(v)
# flat destructuring
for k, v in m.items():
print(k, v)
counter(rx) =
n = 0
while true:
msg = rx.recv()
if msg.is_err():
break
n += msg.val()
print(n)
break and continue work as expected.
| Operator | Description |
|---|---|
+ |
Add (Int/Float) or concatenate (Str) |
- |
Subtract |
* |
Multiply |
/ |
Divide |
% |
Remainder (Int/UInt only) |
Integer division or remainder by zero produces err(DivByZero) — a
recoverable error value, never a crash, never undefined behaviour.
| Operator | Description |
|---|---|
& |
AND |
\| |
OR |
^ |
XOR |
~ |
NOT (unary) |
<< |
Left shift |
>> |
Right shift (arithmetic on Int, logical on UInt) |
& | ^ ~ << >> are always bitwise over integers.
and or not are always logic over Bool. There is no implicit
truthiness — Bool & Bool is a compile error.
or < and < not < comparison < bitwise < additive < multiplicative < unary < postfix
Bitwise sits between comparison and additive:
x & 1 == 0 parses as (x & 1) == 0.
| Operator | Description |
|---|---|
== != |
Equality |
< > <= >= |
Ordering |
is |
Identity |
Comparison is non-associative: a < b < c does not parse.
name = "grl"
greeting = "hello, {name}"
print(greeting) # hello, grl
# escape sequences
print("tab\there")
print("quote: \"")
print("unicode: \u{00E9}") # é
# literal braces
print("use {{ and }} for braces") # use { and } for braces
String interpolation is lexical — the lexer finds the matching } at
the correct nesting depth. Nested string literals inside interpolation are
not allowed in v0.1.
+ on strings concatenates (allocates). In @freestanding code, string
literals exist but concatenation does not (no heap).
.grl has zero exceptions. Errors are values.
content = open("a.txt")? # propagate error to caller
print(content)
r = open("b.txt")
if r.is_err():
print(r.reason())
else:
print(r.value_or(""))
? after any expression propagates the error: if the value is err(...),
the function returns immediately with that error. If it's ok(...), the
value is unwrapped and the expression continues.
? is not allowed inside block lambdas in v0.1 — write a named function.
first = items.first() # Opt[Int]
if first.is_some():
print(first.value_or(0))
| Method | Description |
|---|---|
.is_err() |
True if Res is an error |
.is_ok() |
True if Res is ok |
.reason() |
Error message (Str) |
.value_or(default) |
Unwrap with fallback |
.is_some() |
True if Opt has a value |
.val() |
Unwrap Opt value |
# expression lambda — three parameter shapes
doubles = nums.map(x => x * 2)
evens = nums.filter(x => x % 2 == 0)
total = nums.reduce(0, (acc, x) => acc + x)
# block lambda — body indented, ")" at opener column
names = photos.map(f =>
f.title + " (" + f.year.text() + ")"
)
# zero-param lambda
gui.Button("Next", () => next())
Lambdas capture by move, and only by move (BINDING B32). Once a lambda captures a variable, that variable is consumed — it cannot be used again.
? and : (if) are not allowed inside block lambda bodies in v0.1.
Write a named function instead.
Any function f(a, b) can be called as a.f(b) — uniform function call
syntax. This enables pipelines without special syntax:
photos
.filter(f => f.year == 2026)
.sort((a, b) => a.date - b.date)
.take(20)
.each(f => print(f.name))
A line starting with . continues the expression above (DOT_LEAD). A
trailing binary operator also suppresses the newline:
total = prices.net() +
taxes.compute()
type Point =
x: Float
y: Float
p = Point(3, 4)
print(p.dist(Point(0, 0)))
Types are constructed by calling the type name as a function. Fields are
accessed with .. Methods are defined as regular functions and called via
UFCS.
## reads a file and returns its contents
read_all(path) -> Res[Str] =
fs.read(path)
type HandoffInfo @layout(packed) =
magic: UInt32
fb_base: UInt64 @align(8)
fb_line: UInt32
## is a doc-comment — it attaches to the next function or type definition.
@layout(packed) and @align(8) are layout attributes.
counter(rx) =
n = 0
while true:
msg = rx.recv()
if msg.is_err():
break
n += msg.val()
print(n)
tx, rx = chan(Int)
spawn counter(rx)
tx.send(3)?
chan(T) returns a (tx, rx) pair — a sender and receiver. Both are
move-only (linear): once moved, they cannot be used again. spawn
starts a new actor. tx.send(v)? sends a value.
@freestanding
@entry
boot(info: HandoffInfo) =
fb = FrameBuffer(info.fb)
fb.write("Sografe")
while true:
pause()
@freestanding marks code that will run without an OS — no heap, no
stdlib, no external symbols. The compiler rejects any use of stdlib
functions, allocation, or string concatenation in freestanding files.
import net.http
import net.tcp
Imports are dotted paths. Multiple consecutive imports form a single block in the formatter.
These are not in v0.1 by design, not oversights:
Res, Opt, ?)if x: where x is Int is a compile errorxs[i] — use .at(i), .first(), .find(k), .set(i, v)?: — use if/else&& || — not in the symbol table; use and/or&= |= — compound assignment is closed at += -= *= /=if cond: stmt does not parse| Keyword | Usage |
|---|---|
if else while for in |
Control flow |
return break continue |
Early exit / loop control |
and or not |
Logic (Bool only, short-circuit) |
import |
Module import |
type |
Type definition |
spawn |
Start actor |
is |
Identity comparison |
par region tensor effect async
true, false, none — these are identifiers, not keywords (Go model).
| Operator | Types | Notes |
|---|---|---|
+ |
Int, Float, Str | Str = concatenate (hosted only) |
- |
Int, Float | |
* |
Int, Float | |
/ |
Int, Float, UInt | DivByZero → err(DivByZero) |
% |
Int, UInt only | Sign follows dividend |
| Operator | Types | Notes |
|---|---|---|
& |
Int, UInt | AND |
\| |
Int, UInt | OR |
^ |
Int, UInt | XOR |
~ |
Int, UInt (unary) | NOT |
<< |
Int, UInt | Left shift |
>> |
Int, UInt | Right shift (arithmetic on Int, logical on UInt) |
| Operator | Types | Notes |
|---|---|---|
and |
Bool | Short-circuit, always Bool |
or |
Bool | Short-circuit, always Bool |
not |
Bool (unary) |
| Operator | Notes |
|---|---|
== != |
Equality |
< > <= >= |
Ordering |
is |
Identity |
| Symbol | Meaning |
|---|---|
= |
Assignment / opens definition body |
: |
Opens control-flow body / type annotation |
-> |
Return type |
=> |
Lambda |
? |
Error propagation |
. |
Field access / method call (UFCS) |
@ |
Attribute |
## |
Doc comment |
# |
Line comment |
+= -= *= /= |
Compound assignment |
or → and → not → comparison → bitwise → additive → multiplicative → unary → postfix
| Literal | Type | Example |
|---|---|---|
| Integer (no suffix) | Int (64-bit signed) | 42, 1_000_000 |
| Integer with suffix | UInt of that width | 5u8, 3u16, 10u32, 100u64 |
| Hex | Int | 0xFF, 0xDE_AD |
| Binary | Int | 0b1010, 0b0000_1111 |
| Float (always 64-bit) | Float | 3.14, 1_000.5 |
| String | Str | "hello", "value: {x}" |
| Escape | Meaning |
|---|---|
\n \t \r |
Newline, tab, carriage return |
\" \\ |
Quote, backslash |
\0 |
Null |
\xHH |
Byte (2 hex digits) |
\u{HHHH} |
Unicode codepoint (exactly 4 hex digits) |
{{ }} |
Literal { and } |
| Type | Description |
|---|---|
Int |
64-bit signed integer |
UInt |
64-bit unsigned integer (or u8/u16/u32/u64) |
Float |
64-bit float |
Bool |
Boolean (true / false) |
Str |
UTF-8 string |
List[T] |
Ordered list |
Opt[T] |
Optional value (some(v) / none) |
Res[Str] |
Result or error (ok(v) / err(msg)) |
Map[K, V] |
Key-value map |
Set[T] |
Set |
| Method | Signature | Notes |
|---|---|---|
.at(i) |
-> Opt[T] |
Element access (no indexing []) |
.first() |
-> Opt[T] |
First element |
.find(k) |
-> Opt[T] |
Find by key |
.set(i, v) |
Write element | |
.map(f) |
-> List[U] |
Transform |
.filter(f) |
-> List[T] |
Filter by predicate |
.reduce(init, f) |
-> U |
Fold |
.sort(f) |
-> List[T] |
Sort by comparator |
.take(n) |
-> List[T] |
First n elements |
.each(f) |
Apply to each |
| Method | Description |
|---|---|
.text() |
Convert to text representation |
.count(sub) |
Count occurrences |
| Method | Description |
|---|---|
.is_ok() / .is_err() |
Check Res state |
.is_some() / .is_none() |
Check Opt state |
.reason() |
Error message (Str) |
.value_or(default) |
Unwrap with fallback |
.val() |
Unwrap Opt value |
| Method | Description |
|---|---|
chan(T) |
Create (tx, rx) pair |
tx.send(v) |
Send value |
rx.recv() |
Receive (blocks) |
| Attribute | Applies to | Description |
|---|---|---|
@freestanding |
File | No heap, no stdlib, no external symbols |
@entry |
Function | Entry point (freestanding) |
@layout(packed) |
Type | Packed memory layout |
@align(n) |
Field | Alignment in bytes |
| Command | Description |
|---|---|
grl run <file> |
Compile + execute (JIT) |
grl check <file> |
Parse + typecheck |
grl build <file> |
Compile to IR (writes <file>.grl.ir) |
grl fmt <file> |
Format source (idempotent) |
grl lsp |
Language Server (stdio) |
grl --version |
Version + toolchain |
= aligned in consecutive simple assignments (LHS ≤ 24 chars)fmt(fmt(x)) == fmt(x)#) discarded; doc-comments (##) preservedThe formal grammar specification is in docs/EBNF.md (660 lines).
It is the normative conformance contract for the parser — all 32 numbered
examples in §4 are the parser's permanent test suite.
See EBNF.md in this folder.