The Ten-Minute Tour
A rapid tour of the language: variables, control flow, structs and traits, error handling, the standard library, and tests — in about ten minutes. For toolchain installation and build details, see Getting Started.
Minute 0: What is Ctron
An AI-native systems programming language, designed for "AI writes, humans review": every diagnostic carries a stable error code (E2010, E2080, …), the grammar is unambiguous, and the language does nothing behind your back —
| What Ctron doesn't have | What you use instead |
|---|---|
null |
Option[T] |
| Implicit numeric conversions | explicit x.as[U64]() |
| Operator overloading, macros | + means addition — source is the truth |
| Undefined behavior | the spec enumerates every legal behavior |
One codebase, two execution paths: ctron run interprets (zero dependencies, fastest dev loop), ctron build emits readable, self-contained C and invokes the local cc to produce a native executable. The standard library ships as Ctron source alongside the toolchain; use std.* reads the source at compile time and merges it into a single AST — you can read stdlib source to understand API behavior, no docs required.
Minutes 1–2: Install and run
curl -fsSL https://github.com/ZturnLibs/Ctron/releases/latest/download/install.sh | sh
export PATH="$HOME/.ctron/bin:$PATH"
$ ctron new hello && cd hello # scaffolds Ctron.ctcl (manifest) + src/main.ct
$ ctron run src/main.ct # parse → check → evaluate, one pipeline
hello, ctron
$ ctron check src/main.ct --format=json
{"diagnostics":[]}
$ ctron build src/main.ct # emit C → local cc → executable
$ ./src/main
run / check / new need no C toolchain; only build requires a local cc (override with the CC env var). The manifest is the CTCL-format Ctron.ctcl:
pkg {
manifest_version = 1
name = "hello"
version = "0.1.0"
}
Minutes 2–4: Variables, types, functions
No semicolons — a newline terminates a statement. let is immutable, var is mutable; both must be initialized. Function signatures (parameters, returns, fields, consts) must be annotated — the signature is the contract; local bindings are inferred:
fn main() {
let name = "ctron" // immutable
var n = 3 // mutable
n += 1 // reassigning a let = E2080 compile error
let ratio = 2.5 // floats default to F64
let width: I64 = 1_000_000 // integer literals adapt to the expected type; unconstrained = I32
println("{name} v{n}") // interpolation: field/method chains and indexing work
}
Type system in one glance: fixed-width integers I8…I64/U8…U64, F32/F64, Bool; strings come in two forms — Str (an immutable borrowed view; string literals are Str) and String (GC-heap owned, implicitly degrades to Str; s.to_string() upgrades). No implicit numeric conversions — both widening and narrowing go through as:
fn shout(msg: Str) -> I64 {
let n: U64 = msg.len.as[U64]() // explicit conversion; narrowing truncates
return n.as[I64]() + 1
}
Three details you will use immediately:
assert_eq(7 / 2, 3) // truncating division; checked arithmetic by default
assert_eq(255u8 +% 1u8, 0u8) // +% -% are the only doorway to wrapping arithmetic
assert_eq(21.double(), 42) // UFCS: 21.double() ≡ double(21)
Minutes 4–5: Control flow — everything is an expression
There is no ternary operator — ? and : are taken by other syntax; the if expression is the one and only form:
let label = if n > 0 { "pos" } else { "neg" } // else is required when used as a value
match is an expression too, with compile-time exhaustiveness — add a variant to an enum and every non-wildcard match fails to compile:
match read_file(path) {
Some(s) => { println("got {s.len} bytes") }
None => { println("missing: {path}") }
}
Loops and ranges; break/continue are available:
var sum = 0
for i in 0..5 { sum += i } // 0..5 = 0,1,2,3,4
for i in 0..=5 { sum += i } // inclusive
var n = 1
while n < 100 { n = n * 3 }
Closures are delimited by | and capture a copy of the binding at creation time (mutable sharing only through Mutex/Atomic/Global):
let square = |x: I32| -> I32 { x * 2 } // annotations are elidable in expected-fn-type position
let y = xs
.filter(|x| x > 0) // multi-line chains use leading-dot style:
.map(|x| x * 2) // a line starting with . continues the previous statement
Minutes 5–6: structs / classes / Box and traits
Assignment behavior is written on the type name: struct copies, class shares, Box is an explicit heap cell (access auto-dereferences):
struct Point {
var x: I32
var y: I32
}
class Tag {
let id: I32 // fields are immutable by default; mutable requires var
}
test "three assignment semantics" {
var a = Point { x: 1, y: 2 }
var b = a
b.x = 10 // changes b only: a.x is still 1 (value copy)
let t1 = Tag { id: 7 }
let t2 = t1 // same instance (reference shared)
let p = Box[Point](Point { x: 3, y: 4 })
assert_eq(p.x, 3) // Box auto-dereferences; writes through aliases are visible
}
Traits are nominal and explicitly implemented (orphan rule: the trait or the type must be defined in your package). A prop is a zero-argument read-only computed value, called without parentheses; generics use square brackets, bounds combine with +; @derive synthesizes common impls:
trait Clock {
fn now(&self) -> U64
prop name: Str
}
@derive(Show, Eq)
struct Pixel {
let x: I32
let y: I32
}
struct Pair[A: Show + Eq, B: Show + Eq] { // generics + bounds
let first: A
let second: B
}
impl Clock for FakeClock {
fn now(&self) -> U64 { return self.base }
prop name: Str { return "fake" }
}
The full trait story (two receiver forms, trait objects, the orphan rule, UFCS resolution order, v0 boundaries) is in the appendix below.
Minutes 6–8: No null, and error handling
Errors come in two kinds: expected errors live in the type system (Option/Result), bugs panic. There is no exception mechanism.
@derive(Error) // generates the Error impl for the enum
enum MathErr {
DivByZero
}
fn safe_div(a: I32, b: I32) -> Result[I32, MathErr] {
if b == 0 { return Err(DivByZero) }
return Ok(a / b)
}
fn ratio(a: I32, b: I32, c: I32) -> Result[I32, MathErr] {
let x = safe_div(a, b)? // ?: on Err, return early with location metadata
return safe_div(x, c)
}
Four ways to extract a value (T? is just sugar for Option[T]):
div_opt(1.0, 0.0).or(-1.0) // default on failure (infix: x or default)
ratio(1, 0, 1).expect("must not fail") // panics on failure (message carries the Show form)
ratio(1, 0, 1).context("computing ratio") // wraps the error chain: message/cause/trace
match ratio(1, 0, 2) { // exhaustive branches
Ok(v) => println("ok {v}")
Err(e) => println("err: {e.message}")
}
Discarding a Result/Option return value draws a must-use warning (W8020) — errors must be consumed.
Minutes 8–9: Standard library, collections, tests
use paths always start from the package root / top-level namespace; group imports use braces; wildcard imports are forbidden:
use std.str.{words, count_ch} // std modules: json csv uuid crypto fmap heap
// iter sort strconv rand path time hashmap…
Collections and string building (prelude types — no use needed):
var nums: List[I64] = List[I64]() // note: the array literal [1,2,3] is a fixed-size
nums.push(42) // array; it never becomes a List — use push to grow
nums.push(7)
assert_eq(nums.len, 2)
assert_eq(nums[0], 42) // indexing is always bounds-checked; out of range panics
var sb = StringBuilder()
sb.push_str("hello")
sb.push_str(", ctron")
assert_eq(sb.to_string(), "hello, ctron")
Tests are in-language test blocks, run with ctron test; a failed assertion panics with a message carrying the Show form of expected and actual values:
test "loops and ranges" {
var sum = 0
for i in 0..5 { sum += i }
assert_eq(sum, 10)
}
ctron test tests/01_basics.ct # run test blocks
ctron fmt -w . # canonical formatting (one form repo-wide)
ctron lint --strict . # warnings fail too — good for CI
Minute 10: A GUI teaser, and the pitfalls
GUI is a first-class domain package — UI lives in .ctml markup, logic stays in Ctron, and the platform (parsing, layout, hit-testing, the event loop) lives entirely in the gui package: user code writes no event loop at all (see the complete runnable examples/gui_counter in the repository):
view Counter {
<vbox class="root">
<label>count: {count}</label>
<button class="btn" on:click={inc}>+1</button>
</vbox>
}
The ten most common beginner pitfalls:
- No semicolons — a newline ends the statement (
;is a syntax error);} else {must stay on one line. - No ternary — the
if cond { a } else { b }expression is the only form. ||is logical or —oris only for Option/Result defaults; bitwise operations are not language operators (use std's bit module).- Comparisons don't chain — write
a < b && b < c. - No implicit numeric conversions —
n.as[U64](); unconstrained integer literals default toI32. - A literal
{in a string is\{— no nested{}inside interpolation; string literals cannot span lines. [1, 2, 3]is a fixed-size array (it degrades to a slice); for a growable list useList[I32]()+push.- struct assigns by copy, class by reference, Box aliases share — fields are immutable by default; mutable requires
var. letbindings cannot be reassigned (including+=, E2080); closures capture a snapshot at creation.- match must be exhaustive — add an enum variant and every non-wildcard match is a compile error.
Next steps: the language spec lives under Spec (12 chapters, frozen draft v0.8); complete runnable applications are in the repository's examples/ directory (ctwc for CLI, todo_app for network services, gui_calc for GUI); toolchain and project-mode details are under Getting Started.
Appendix: Traits in depth — seven cases
One idea first: Ctron is a nominal-trait language — methods come only from explicit impl; there is no inheritance and no implicit implementation. A type's behavior comes from impl Trait for Type, free functions + UFCS, and (for classes) methods declared in the class body. A struct's declaration body has fields only, no methods — the behavior surface can always be found in an impl block.
① Basics: trait declaration, impl, two receiver forms
There are exactly two receivers: &self (a read-only view) and var self (mutable — the body can advance state); mutable sharing across boundaries goes through var parameters or Mutex:
struct Counter {
var cur: I32
let limit: I32
}
trait Iterator[T] {
fn next(var self) -> T? // var self: state-machine traits take a mutable receiver
}
impl Iterator[I32] for Counter { // impl must pin every type parameter (monomorphization)
fn next(var self) -> I32? {
if self.cur >= self.limit { return None }
let v = self.cur
self.cur += 1
return Some(v)
}
}
test "for iterates any Iterator" {
let c = Counter { cur: 0, limit: 4 }
var acc = 0
for v in c { acc += v } // implementing Iterator[T] enables for
assert_eq(acc, 6)
}
A trait may contain method signatures, prop signatures, and default method bodies — never fields; state always lives in the implementing type.
② props, default bodies, supertraits
A prop is a zero-argument read-only computed value, called without parentheses (xs.len and e.message are props); it must be pure and side-effect free. Default method bodies may call supertrait members, and an empty impl inherits them:
trait Clock: Cap { // : Cap only for I/O capability traits (see ④)
fn now(&self) -> U64
}
trait Named: Cap {
prop name: Str // trait property declaration
}
trait Env: Clock + Named { // supertraits: implementing Env requires both
fn describe(&self) -> Str { // default method body
return "{self.name} @ {self.now()}"
}
}
class FakeEnv {
let base: U64
}
impl Clock for FakeEnv {
fn now(&self) -> U64 { return self.base }
}
impl Named for FakeEnv {
prop name: Str { return "fake" }
}
impl Env for FakeEnv { // describe uses the default body → empty impl is legal
}
test "props, default methods, supertraits" {
let env = FakeEnv { base: 100 }
assert_eq(env.now(), 100)
assert_eq(env.name, "fake") // props take no parentheses
assert_eq(env.describe(), "fake @ 100")
}
③ Generics + bounds: constrain at the signature, unlock derived methods in the body
Bounds sit on the type parameters and combine with +; with a bound in hand, derived methods like .show()/.eq() become callable inside the generic body:
@derive(Show, Eq)
struct Pixel {
let x: I32
let y: I32
}
fn render[T: Show](v: T) -> Str { // bound: T must satisfy Show
return v.show() // without that bound, this call is unavailable
}
struct Pair[A: Show + Eq, B: Show + Eq] {
let first: A
let second: B
}
Show/Eq satisfaction is a structured predicate — no per-type impl needed: a struct whose fields are all scalars/Str/derivable value types automatically has both methods (recursive, depth limit 6); List/Atomic/enum/class fields are not derivable. The normative .show() format is Name(field=value,field=value) (declaration order, comma-separated). @derive(Error) likewise generates message/cause/trace for error enums. An impl may also carry its own type parameters to implement a generic type: impl[T] Seq[T, List[T]] for List[T].
④ Trait objects &Trait: dependency injection, the intended way
&Trait is the only trait-object form in v0 (a read-only borrow, dynamically dispatched). Upcasts are implicit (&FakeClock → &Clock); downcasts are forbidden. A value passed to a &Trait parameter is borrowed automatically — tests inject fake implementations:
trait Clock: Cap { // capability traits must inherit Cap (an empty marker)
fn now(&self) -> U64 // concrete types never implement Cap; it only tags the trait
}
class FakeClock {
let base: U64 // let fields → deeply immutable → Send
}
impl Clock for FakeClock {
fn now(&self) -> U64 { return self.base }
}
fn elapsed_since(clock: &Clock, start: U64) -> U64 {
return clock.now() - start // dynamic dispatch through the trait object
}
test "capability injection via trait object" {
let clock = FakeClock { base: 100 }
assert_eq(elapsed_since(clock, 58), 42) // value → &Clock: auto-borrow + upcast
}
: Cap is only needed when the trait represents an I/O capability (it participates in #[pure]/manifest capability checks); ordinary traits don't write it. The std Fs injection (fn read_a(fs: &Fs) -> Result[String, FsError] plus a MemFs fake) is the same pattern in production form — see tests/07a_cap_fs_inject.ct in the repository.
⑤ The orphan rule: E5010 and the newtype pattern
impl T for X is legal iff the trait or the type is defined in the current package — no generic exceptions. Implementing a prelude type directly is a compile error; the standard fix is a local newtype wrapper:
// impl Show for I32 // ✗ E5010: Show and I32 both come from the prelude
struct UserId {
let v: I32
}
impl Show for UserId { // ✓ UserId is a local type → legal
fn show(&self) -> Str {
return "UserId({self.v})"
}
}
⑥ UFCS: free functions are methods, with a fixed resolution order
recv.m(a) first resolves as "the function taking recv as its first argument", in this order: the type's own inherent methods → impl methods of visible traits → the prelude. Free functions grow on their receivers naturally:
fn double(x: I32) -> I32 { return x * 2 }
test "ufcs" {
assert_eq(double(21), 42)
assert_eq(21.double(), 42) // two spellings, one function
}
The std iteration chain is the canonical "two-parameter trait + UFCS free functions" assembly — the adapter methods of trait Seq[T, S] (map/filter/take…) carry default bodies, the terminators sum/count/collect are free functions, and xs.map(|x| x * 2).filter(|x| x > 4).sum() runs lazily with zero intermediate collections. Runnable anchor: tests/modules/iter_adapters in the repository.
⑦ v0 boundaries (known limitations)
- Trait declarations take no
pub— v0 has no pub-trait syntax; trait interface faces are always visible across modules; visibility is marked on members only. - Trait objects are
&Traitread-only borrows only; the owned formBox[&Trait]is a reserved slot, not yet implemented. &Traitis conservatively non-Send; a class withvarfields is non-Send as a whole — cross-task sharing goes throughMutex.- An impl's type parameters must be fully pinned (
impl Iterator[I32] for Counter); the "implement generic-for-generic" form isimpl[T] ... for Type[T]. - Prelude symbols can be shadowed by local declarations (lint-hinted), but the orphan rule blocks impls, not names.