This chapter is a hands-on introduction to Scaly. It gets you from an empty file to a running program, then takes you on a quick tour of the language. Later chapters cover everything here in depth; the goal now is to write and run real code as fast as possible.
The quickest way to get the compiler is the installer hosted on scaly.io. Scaly ships as its own LLVM IR (a seed) and is turned into a native binary on your own machine, so first install LLVM 18 and a C compiler:
; macOS brew install llvm@18 ; Ubuntu / Debian sudo apt install llvm-18 clang
Then run the installer. It downloads Scaly, builds it for your
machine, and puts scalyc on your PATH
under ~/.scaly — no sudo
required:
curl -fsSL https://scaly.io/install.sh | sh
Open a new terminal afterwards so the updated PATH
takes effect; then scalyc works from any directory, as
the rest of this tutorial assumes. To uninstall, remove
~/.scaly and the PATH line the
installer added to your shell profile.
If you have the source repository — for contributing to the compiler itself — you can build scalyc directly instead of using the installer. Scaly is self-hosted: the compiler is written in Scaly and compiles itself, distributed as a seed — the compiler emitted as its own LLVM IR, the .ll files committed under seed/. A single seed serves every 64-bit little-endian target — the IR carries no target triple, so the compiler you build from it targets whatever machine it runs on. Turning the seed back into a working scalyc needs only LLVM 18 and a C compiler for the final link; no C++ toolchain is involved.
With the prerequisites above installed, run the build script from the repository root:
tools/build-from-seed.sh
This compiles the seed with llc, links it into
scalyc/build/scalyc, and builds the small runtime
archive the compiler links your programs against. To put it on your
PATH from a repository checkout:
tools/install.sh
This installs a small scalyc wrapper in
/usr/local/bin (override with BINDIR,
e.g. BINDIR="$HOME/.local/bin" tools/install.sh)
that points back at the compiler in this repository and sets
SCALY_HOME so the prelude and standard library are found
whatever your current directory is; rebuilding the compiler is picked up
automatically with no reinstall. Remove it again with
tools/uninstall.sh.
Note: tools/install.sh builds the small runtime archive into a persistent location under the repository, and the installed scalyc rebuilds it automatically if it ever goes missing. If you instead run scalyc/build/scalyc directly without installing, its archive lives at /tmp/libscaly.a, which is cleared on reboot; re-run tools/build-from-seed.sh to rebuild it.
The C++ bootstrap that originally produced the seed is described in Appendix B; you do not need it for everyday use.
A Scaly program is just a file of statements — there is no main function to declare and nothing to import. Put this single line in a file called hello.scaly:
print("Hello, World!")Compile it to an executable with -o, then run the
result:
scalyc -o hello hello.scaly ./hello
The program prints:
Hello, World!
That is the whole edit-compile-run loop. Everything that follows is just more interesting things to put between the braces.
You can drive the whole edit-compile-run-debug loop from
Visual Studio Code: a build task to compile the
file you are editing, a run task to execute it, and a launch
configuration that stops at breakpoints and lets you step through your
Scaly source. Debugging uses the DWARF information the compiler emits with
the -g flag, so breakpoints land on real source lines and
scalar local variables show their values.
Open your project folder as your VS Code workspace and install the
CodeLLDB extension (extension id
vadimcn.vscode-lldb), which provides the
lldb debugger type used below. Reload the window afterwards
( > ) so the extension activates; if
F5 reports configured debug type 'lldb' is not
supported, CodeLLDB is not installed or not enabled. The tasks call
scalyc by name, so install it on your
PATH first (see the Section called Installing Scaly). If
Visual Studio Code was already running when you
installed scalyc, restart it so its process picks up
the updated PATH — otherwise the tasks will fail to find
the compiler.
Note: Because the installed scalyc wrapper sets
SCALY_HOMEfor you, the compiler finds the prelude (which defines print and friends) and the standard library no matter where your .scaly files live — you do not have to work inside the compiler's own source tree. The tasks set cwd to ${workspaceFolder} only so the compiled executable lands beside your source.
Create .vscode/tasks.json with a build task that compiles the open file with debug info, and a run task that executes the result:
{
"version": "2.0.0",
"tasks": [
{
"label": "scaly: build (debug)",
"type": "shell",
"command": "scalyc",
"args": ["-g", "-o", "${fileBasenameNoExtension}", "${file}"],
"options": { "cwd": "${workspaceFolder}" },
"group": { "kind": "build", "isDefault": true },
"problemMatcher": {
"owner": "scaly",
"fileLocation": ["autoDetect", "${workspaceFolder}"],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(error|warning):\\s+(.*)$",
"file": 1, "line": 2, "column": 3,
"severity": 4, "message": 5
}
}
},
{
"label": "scaly: run",
"type": "shell",
"command": "${workspaceFolder}/${fileBasenameNoExtension}",
"options": { "cwd": "${workspaceFolder}" },
"dependsOn": "scaly: build (debug)"
}
]
}Press Cmd+Shift+B (or Ctrl+Shift+B) to build the file in the active editor. The problemMatcher turns any compiler diagnostic into a clickable entry in the Problems panel. Use and pick to build and execute.
Create .vscode/launch.json so F5 builds the open file and starts it under the debugger:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Scaly file",
"type": "lldb",
"request": "launch",
"preLaunchTask": "scaly: build (debug)",
"program": "${workspaceFolder}/${fileBasenameNoExtension}",
"args": [],
"cwd": "${workspaceFolder}"
}
]
}By default VS Code only lets you set breakpoints in files whose language it recognizes and for which an installed debugger has declared breakpoint support. Languages like C# ship a VS Code extension that does both, so breakpoints work there out of the box. Scaly has no such extension yet, so .scaly files are treated as plain text — clicking the gutter does nothing and no red dot appears.
Until a Scaly language extension exists, lift that restriction by creating .vscode/settings.json:
{
"debug.allowBreakpointsEverywhere": true
}With this set, breakpoints can be placed in any file, including .scaly. (The same effect is available globally via > if you prefer it for every workspace.)
Put this program in area.scaly at the repository root. It computes a rectangle's area from two mutable bindings — small, but enough to stop and inspect some values:
var width 8
var height 5
var area width * height
if area = 40
print("the area is 40")
else
print("unexpected area")Click in the gutter to the left of the var area width * height line to set a breakpoint (a red dot appears), then press F5. The program builds and runs, and execution stops at that line before it executes — at which point width and height already have values but area does not yet.
With execution paused you can:
Read width and height in the Variables pane, or hover them in the editor to see their values.
Step one line at a time with F10 () — after stepping past the breakpoint line, area appears with the value 40. Step into a called function with F11.
Press F5 again to continue to the program's end.
Breakpoints, stepping, and variable inspection all rely on the
-g debug information; without it the program still runs,
but the debugger has no source lines to stop on.
Stepping becomes more interesting once a function is involved. F10 () runs a call as a single step; F11 () descends into the called function so you can watch it run. Put this program in rectangle.scaly at the repository root — it factors the area calculation out into a function:
function area(w: int, h: int) returns int
{
var result w * h
result
}
var width 8
var height 5
var a area(width, height)
if a = 40
print("the area is 40")
else
print("unexpected area")Set a breakpoint on the var a area(width, height) line and press F5. When execution stops there, width and height show in the Variables pane. Now press F11 instead of F10: the editor jumps into the body of area and stops on var result w * h.
The Call Stack pane (bottom-left of the Run and Debug view) now lists two frames — area on top and main below it — showing exactly how you got here. Click a frame to switch the Variables pane and the editor to that frame's context: selecting main shows width and height again, while area shows its own local result (still unassigned at this point, just as area was before its line ran in the previous example).
From inside the function you can keep stepping, press Shift+F11 () to return to the caller, or F5 to continue to the end. The function-and-procedure model is covered in the Section called Functions and the Section called Procedures and Mutation.
Debug support tracks the parts of the language that are most useful to inspect today, and is being widened over time. At present:
Breakpoints land on top-level statements, bindings, and function bodies.
The Variables pane shows scalar var locals — integers and booleans — for whichever stack frame is selected.
Breakpoints and stepping work line-by-line inside loop and conditional bodies, so you can stop on individual statements within a while, for, if, or choose body, not just on the enclosing statement.
Function parameters, let (immutable) bindings, and aggregate values such as structures and strings are not shown yet.
The Watch view and Debug Console evaluate expressions as C/C++ and do not understand Scaly names. To read a value, use the Variables pane or hover over the name in the editor.
This section sweeps quickly across the language using small, complete programs. If something is not fully explained here, do not worry — every feature is covered in detail in the rest of this specification, and each part of the tour points you to the relevant chapter.
Bind a value to a name with let. There is no equals sign — the name is followed directly by its value. Use var for a binding you intend to change, and set to change it. A semicolon starts a comment.
let answer 42 ; immutable binding var count 0 ; mutable binding set count: count + 1 ; assignment uses a colon
The equals sign = is reserved for comparison, so it never gets confused with assignment. See Chapter 3 for the full lexical rules.
Operators are ordinary identifiers, not keywords baked into the grammar. When they are chained, they combine using standard mathematical precedence — multiplication binds tighter than addition:
; 3 + 4 * 5 is 3 + 20, which is 23 — not 35
let result 3 + 4 * 5
if result = 23
print("got 23")
else
print("unexpected")Parentheses group explicitly, and an operator written before its operand acts as a prefix function (-x, ~flag). See Chapter 7 for the complete model.
if chooses between branches. Single-statement branches need no braces; the condition needs no parentheses:
let n 7
if n < 0
print("negative")
else if n = 0
print("zero")
else
print("positive")while repeats as long as its condition holds. Use braces for a multi-statement body:
var sum 0
var i 1
while i <= 10
{
set sum: sum + i
set i: i + 1
}
; sum is now 55for iterates. for i in 4 runs i from 0 up to — but not including — 4:
var total 0
for i in 4
set total: total + i
; total is 0 + 1 + 2 + 3 = 6A function declares its parameter types and return type. The value of the last expression is returned automatically — no return keyword is needed for the final result. A single-expression body needs no braces:
function add(a: int, b: int) returns int
a + b
let s add(3, 4) ; s is 7Functions can call themselves. Here is the classic factorial:
function factorial(n: int) returns int
if n <= 1
1
else
n * factorial(n - 1)
let f factorial(5) ; f is 120Functions are pure: they read their arguments and return a value, with no side effects. To mutate, use a procedure.
A procedure is like a function but may change its inputs and perform side effects. The distinction is visible at every call site, so a reader always knows which calls can mutate state. See the Section called Mutation via Procedures in Chapter 4 for the details.
define Counter(value: int)
{
procedure increment(this)
set value: value + 1
function get(this) returns int
value
}
var c Counter(0)
c.increment()
c.increment()
let v c.get() ; v is 2Define a structure by listing its fields. A structure with public fields gets a constructor that takes them in order. Methods are functions or procedures whose first parameter is this:
define Point(x: int, y: int)
{
function distance_squared(this) returns int
this.x * this.x + this.y * this.y
}
let p Point(3, 4)
let d p.distance_squared() ; d is 25the Section called Constructors in Chapter 4 covers constructors, including the page-aware init# form used by types that allocate.
A union holds one of several variants. Each variant may carry a payload. The most common union is Option, which represents a value that may be absent:
define Option union(Some: int, None) let present Option.Some(42) let absent Option.None
Match on a union with choose. Each when clause names a binding, then the variant, then the result; the binding is bound to the variant's payload. An else clause handles the remaining variants:
function or_zero(o: Option) returns int
choose o
when v: Some
v
else
0
let a or_zero(Option.Some(7)) ; a is 7
let b or_zero(Option.None) ; b is 0See the Section called Union Types in Chapter 4 for union layout, the T? shorthand, and pattern matching in full.
Let us combine the pieces into one small, complete program: a safe division that returns an Option instead of crashing on a zero divisor, with the caller deciding what to print.
Start with the function. It returns None when the divisor is zero, and Some with the quotient otherwise:
define Option union(Some: int, None)
function safe_divide(a: int, b: int) returns Option
if b = 0
Option.None
else
Option.Some(a / b)Now call it and react to the result with choose. The Some arm receives the quotient; the else arm handles division by zero:
let result safe_divide(20, 4)
choose result
when q: Some
if q = 5
print("20 / 4 = 5")
else
print("unexpected quotient")
else
print("cannot divide by zero")Put both pieces in divide.scaly, then build and run it:
scalyc -o divide divide.scaly ./divide
It prints:
20 / 4 = 5
Change the call to safe_divide(7, 0) and the same program prints cannot divide by zero — the union makes the failure a value you must handle, not a crash you can forget.
From here, the rest of this specification fills in the details: the Chapter 3 rules, the full Chapter 7 model, and the Chapter 4 system including generics, lifetimes, and region-based memory management.