Chapter 2 Tutorial

Table of Contents
Getting Started
Using Scaly with VS Code
A Tour of Scaly
Putting It Together

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.

Getting Started

Installing Scaly

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.

Building from Source

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.

Hello, World!

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.

Using Scaly with VS Code

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 (Command Palette > Developer: Reload Window) 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_HOME for 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.

Build and Run Tasks

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 Terminal > Run Task and pick scaly: run to build and execute.

Launch Configuration

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}"
        }
    ]
}

Allowing Breakpoints in Scaly Files

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 Command Palette > Preferences: Open User Settings (JSON) if you prefer it for every workspace.)

Setting a Breakpoint and Stepping

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 (Step Over) — 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 into Functions and the Call Stack

Stepping becomes more interesting once a function is involved. F10 (Step Over) runs a call as a single step; F11 (Step Into) 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 (Step Out) 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.

What the Debugger Shows

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.

A Tour of Scaly

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.

Values and Bindings

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 and Precedence

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.

Control Flow

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 55

for 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 = 6

Functions

A 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 7

Functions 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 120

Functions are pure: they read their arguments and return a value, with no side effects. To mutate, use a procedure.

Procedures and Mutation

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 2

Structures and Methods

Define 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 25

the Section called Constructors in Chapter 4 covers constructors, including the page-aware init# form used by types that allocate.

Unions, Options, and choose

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 0

See the Section called Union Types in Chapter 4 for union layout, the T? shorthand, and pattern matching in full.

Putting It Together

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.