The Loup Scripting Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: Variables and Data Types
  3. Chapter 3: Functions and Closures
  4. Chapter 4: Control Flow
  5. Chapter 5: Collections
  6. Chapter 6: Structs
  7. Chapter 7: Enums and Pattern Matching
  8. Chapter 8: Classes and Objects
  9. Chapter 9: Interfaces and Generics
  10. Chapter 10: Error Handling
  11. Chapter 11: Modules and Packages
  12. Chapter 12: Strings, Interpolation, and Template Fences
  13. Chapter 13: Concurrency
  14. Chapter 14: Reflection and Dynamic Compilation
  15. Chapter 15: Reactive UI
  16. Chapter 16: Testing
  17. Chapter 17: JavaScript Host Interop
  18. Chapter 18: Building Script Tools
  19. Chapter 19: Memory Safety and Resource Management

Chapter 1: Getting Started

What you'll learn: how to install Loup, run your first script, read the two kinds of error Loup reports, give a script the permissions it needs, and turn a script into a program with a main() function when it grows up.

Loup is a statically typed language with six execution modes. This book is about the one you reach for first: host.scripted, the mode for scripts. A script is a file whose top-level statements run in order, like a Python or shell script. It is checked by the type checker before a single line runs, so a typo in the last line of a long script is reported before the first line does anything.

What Is a Script?

A Loup script is a source file that starts with @mode(host.scripted), has the extension .lpscr, and is run with loup run:

loup run hello.lpscr

There is no build step and no project manifest. loup run reads the file and everything it imports, type-checks the whole program, and then executes it on your machine as an ordinary operating-system process: it can read and write files, run other programs and read environment variables, subject to the permissions described later in this chapter.

How loup run works today. The Language Reference (§1-2.1) describes script mode as a JIT pipeline that compiles each statement to WebAssembly and caches the result. The 1.25 toolchain does not do that: loup run type-checks the program and then runs it on an interpreter built into the loup binary. Scripts do not run on Node, Deno, Bun or in a browser. What you observe as a script author is the same either way: no build step, and type errors are reported before anything runs.

Five other modes exist for code that is compiled ahead of time — host.dual and host.native for programs, web.scripted and web.compiled for the browser, and freestanding for bare metal. You will meet host.dual at the end of this chapter, because it is where a script goes when it becomes a program.

Installing Loup

Official Loup builds are published on GitHub at code-craft-ai-company/loup-releases. The installer downloads the archive for your machine, checks it against the release's signed SHA256SUMS, unpacks it, and puts its bin directory on your PATH. It needs no administrator rights.

On Linux and macOS:

curl -fsSL https://github.com/code-craft-ai-company/loup-releases/releases/latest/download/loup-install.sh | sh

On Windows, in PowerShell:

irm https://github.com/code-craft-ai-company/loup-releases/releases/latest/download/loup-install.ps1 | iex

The toolchain lands in ~/.loup (Linux, macOS) or %LOCALAPPDATA%\Programs\Loup (Windows). To install a particular version, pass it to the script — curl -fsSL … | sh -s -- --version 1.25.1 — or set $env:LOUP_VERSION = "1.25.1" before the Windows command.

Open a new terminal and check the installation:

loup --version

You should see a line that starts with loup 1.25.

The archive contains two compilers. loup-bootstrap is the full toolchain and is the same on every platform. loup is a second name for loup-bootstrap on macOS, Windows and Linux arm64; on Linux x86-64, loup is a newer self-hosted driver that runs scripts, builds native programs and runs tests, but does not yet provide init, check or repl. This book writes loup run for running scripts and loup-bootstrap for the project commands, so every command works on every platform.

Note for macOS. The macOS binaries are not notarized yet. The installer is unaffected, but if you download an archive with a browser, clear the quarantine flag before running it: xattr -dr com.apple.quarantine ~/.loup.

Hello, World!

Create a file called hello.lpscr:

@mode(host.scripted)

println("Hello, world!");

Listing 1-1: hello.lpscr, the smallest complete script

Run it:

loup run hello.lpscr
Hello, world!

The first line, @mode(host.scripted), declares the file's execution mode. It must be the first thing in the file; only comments may come before it. If you leave it out, loup run still treats a .lpscr file as a script, but the language rules say every file declares its mode in its own text (ruling MODE-2), and every listing in this book does.

println writes its argument and a newline. print writes without the newline, which is useful when you build up one line from several pieces.

Watch the mode name. Older Loup material uses @mode(scripted). That name was retired in Loup 1.17. Today the compiler does not reject an unknown or retired mode name — it silently treats the file as host.scripted — so a wrong name in a script goes unnoticed, while a wrong name in a program means its main() is never called. Always write one of the six current names.

Statements Run in Order

A script's top-level statements run once each, from the top of the file to the bottom:

@mode(host.scripted)

let price = 40;
println(price);
let withTax = price * 2;
println(withTax);
40
80

Semicolons are optional. A newline ends a statement, and an expression that ends with an operator or an open bracket continues on the next line:

let base = 10
let total = base +
    5
let names = [
    "Ada",
    "Grace",
]
println(total)
println(names)
15
["Ada", "Grace"]

This book writes the semicolons, as most Loup code does.

Functions can be called before the line that declares them: declarations are visible throughout the file, while statements run in order.

Anatomy of a Script

Here is a slightly larger script:

@mode(host.scripted)

# A comment runs from `#` to the end of the line.

## A doc comment starts with `##` and documents the next declaration.
func greet(name: string): string {
    return i"Hello, {$ name $}!";
}

let message = greet("Loup");
println(message);

Listing 1-2: Comments, a function, an interpolated string and a binding

Hello, Loup!
  • Comments start with #. A doc comment starts with ## and attaches to the declaration that follows it; documentation tools read it.
  • func greet(name: string): string { … } declares a function with one string parameter that returns a string. Parameter types and the return type are written after a colon. Chapter 3 covers functions.
  • i"Hello, {$ name $}!" is an interpolated string. The i prefix turns it on, and {$ … $} inserts the value of an expression. A plain string such as "Hello, {name}" is printed exactly as written. Chapter 12 covers strings.
  • let message = …; declares an immutable binding. Use var for a binding you will reassign. Chapter 2 covers variables.

Mistake: a // comment

If you come from C, Java or JavaScript, your fingers will type // for a comment:

This code does not compile
@mode(host.scripted)

// Say hello
println("Hello!");
main.lpscr:3:1: error[E0001]: expected expression
main.lpscr:3:4: error[E0003]: expected `;`

In Loup, // is not a comment at all: it is the floor-division operator (7 // 2 is 3), so the parser sees an operator with nothing on its left. Use #:

@mode(host.scripted)

# Say hello
println("Hello!");

A Second Script: Two Small Functions

@mode(host.scripted)

## The larger of two integers.
func larger(a: int, b: int): int {
    if (a > b) {
        return a;
    } else {
        return b;
    }
}

## The absolute value of an integer.
func absolute(n: int): int {
    return if (n >= 0) { n } else { -n };
}

println(i"The larger number is {$ larger(17, 42) $}");

let negative = -15;
println(i"The absolute value of {$ negative $} is {$ absolute(negative) $}");

Listing 1-3: if as a statement and as an expression

The larger number is 42
The absolute value of -15 is 15
  • int is a 64-bit signed integer. Chapter 2 covers the other numeric types.
  • The condition of an if is always written in parentheses, and the branches are always blocks in braces.
  • if is also an expression: if (n >= 0) { n } else { -n } evaluates to the value of the branch that runs.

Mistake: the ternary operator

Most C-family languages write a one-line choice as condition ? a : b. Loup has no ternary operator:

This code does not compile
@mode(host.scripted)

func absolute(n: int): int {
    return n >= 0 ? n : -n;
}

println(absolute(-15));
main.lpscr:4:21: error[E0003]: expected `;`

The parser read return n >= 0 as a complete statement and then found a ? where a statement should end. The message does not mention a ternary, so remember this one: when expected ';' points into the middle of a line, look for syntax borrowed from another language. The fix is the if expression from Listing 1-3: return if (n >= 0) { n } else { -n };.

Reading Error Messages

Loup reports two kinds of error, and they look different.

Compile errors are found before the script runs. Nothing in the script executes, and loup run exits with status 2:

This code does not compile
@mode(host.scripted)

let attempts = 1;
attempts = attempts + 1;
println(attempts);
main.lpscr:4:1: error[E0001]: cannot assign to immutable binding 'attempts'

Each diagnostic is one line: the file, line and column, the error code in brackets, and the message. Here attempts was declared with let, which cannot be reassigned. Declare it with var:

@mode(host.scripted)

var attempts = 1;
attempts = attempts + 1;
println(attempts);
2

Some codes are generic (E0001, E0003) and some are specific (E_TOPLEVEL_CONTROL_AOT, which you will see later in this chapter). When the code is specific, it is repeated at the start of the message.

Run-time errors happen while the script runs. An exception that nothing catches stops the script, prints the exception and the call stack, and exits with status 1:

This code throws at run time
@mode(host.scripted)

func firstWord(sentence: string): string {
    let words = sentence.split(" ");
    return words[0];
}

println(firstWord("hello there"));

let noWords: list[string] = [];
println(noWords[0]);
hello
Uncaught IndexError: list index 0 out of range (len 0)
  at <main> (main.lpscr:11:9)

The output before the error is still printed; the trace says which exception escaped (IndexError), why, and where. Chapter 10 shows how to catch exceptions.

When a project grows, loup-bootstrap check (see "Projects" below) reports the same compile errors in a longer form with the source line and a caret under the problem:

error[E0001]: cannot assign to immutable binding 'count'
 --> src/main.loup:5:5
  |
5 |     count = count + 1;
  |     ^^^^^^^^^^^^^^^^^
  |

Making a File Both Runnable and Importable

A file can be a library for other scripts and a script of its own. The variable __name__ is "__main__" in the file you pass to loup run, so a block guarded by it runs only then:

@mode(host.scripted)

## n! computed recursively.
pub func factorial(n: int): int {
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

if (__name__ == "__main__") {
    for (n in [0, 1, 5, 10]) {
        println(i"{$ n $}! = {$ factorial(n) $}");
    }
}

Listing 1-4: factorial.lpscr, a module with a main guard

0! = 1
1! = 1
5! = 120
10! = 3628800

pub makes factorial visible to other files. A second script in the same directory can import it:

Fragment — not a complete program
@mode(host.scripted)

from factorial import (factorial);

println(i"20! = {$ factorial(20) $}");

Listing 1-5: report.lpscr imports factorial.lpscr (a two-file example)

20! = 2432902008176640000

The import names the file without its extension, and the imported names go in parentheses. The guarded loop in factorial.lpscr does not run when it is imported. Chapter 11 covers modules.

Imports today. The Language Reference says an imported module's top-level statements run once, when it is first loaded, with __name__ set to the module's dotted name. In Loup 1.25, loup run compiles the entry script and its imports as one program: an imported file's declarations and let initializers take effect, but its other top-level statements (such as a println or the guarded block) do not run, and __name__ reads "__main__" everywhere. Keep a library's top level to declarations and you will not notice the difference.

Three read-only variables describe the running file:

@mode(host.scripted)

println(i"name: {$ __name__ $}");
println(i"file: {$ __file__ $}");
println(i"mode: {$ __execmode__ $}");
name: __main__
file: main.lpscr
mode: interpreted

__file__ is the path as you passed it to loup run. The Language Reference (§1-2.2) says __file__ is absolute and __execmode__ is the mode name ("host.scripted"); the 1.25 interpreter prints the path as given and interpreted, so do not compare __execmode__ against a mode name yet.

Permissions

A script can only touch the outside world through capabilities granted by loup run. The capabilities are fs (files), proc (running programs), net (sockets), entropy (secure random numbers) and debug.

loup run grants fs, proc and entropy by default, but not net. A script that opens a socket without --allow-net fails when it tries:

Uncaught IoError: capability 'net' is not available: not in permission set

The flags adjust the set:

Flag Effect
--allow-net, --allow-fs, --allow-proc, --allow-entropy, --allow-debug add one capability
--allow-all grant every capability
--deny-all start from nothing; combine with --allow-… to grant exactly what you list

Flags go before the script name; arguments for the script go after it:

loup run --deny-all --allow-fs wordcount.lpscr notes.txt

A script that writes a file under --deny-all stops at the first write:

Uncaught IoError: capability 'fs' is not available: not in permission set
  at <main> (main.lpscr:3:1)

Platform difference. These defaults and flags belong to loup-bootstrap, which is loup on macOS, Windows and Linux arm64. The self-hosted loup on Linux x86-64 grants every capability and ignores the --allow-* and --deny-all flags; use loup-bootstrap run there when you want the sandbox.

The REPL

loup-bootstrap repl starts an interactive session, which is handy for trying out a method or an expression:

Loup REPL — type :help for commands, :quit to exit

>>> let x = 42
>>> println(x * 2)
84
>>> func greet(name: string): string { return i"Hello, {$ name $}!"; }
>>> println(greet("world"))
Hello, world!
>>> :quit

REPL commands start with a colon: :help and :quit. To continue a statement on the next line, end the line with { or \.

From Script to Program: host.dual

When a script grows into a tool you want to ship, move it to host.dual. A host.dual file uses the extension .loup, keeps all its statements inside a main() function, and can be run by loup run or compiled ahead of time to a WebAssembly module or a native binary, with the same behaviour on both paths:

@mode(host.dual)

func factorial(n: int): int {
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

func main(): int {
    for (n in [0, 1, 5, 10]) {
        println(i"{$ n $}! = {$ factorial(n) $}");
    }
    return 0;
}

Listing 1-6: factorial.loup, the same program in host.dual

loup run factorial.loup calls main() for you. main takes no parameters and returns void or int; an int result becomes the process exit status. Command-line arguments come from runtime.args().

Two rules catch people moving a script to host.dual. First, code outside a function is not allowed:

This code does not compile
@mode(host.dual)

func main(): void {
    println("in main");
}

println("loaded");
main.loup:7:1: error[E_TOPLEVEL_CONTROL_AOT]: E_TOPLEVEL_CONTROL_AOT: top-level control flow is not allowed in `host.dual` — only declarations and constant initializers are permitted at module scope; the runtime invokes `main()` after module initialization (§1-2.4)

A compiled program has no "run the file from the top" step; move the statement into main().

Second, main does not receive the arguments as a parameter:

This code does not compile
@mode(host.dual)

func main(args: list[string]): void {
    println(args);
}
main.loup:3:11: error[E_MAIN_SIGNATURE]: E_MAIN_SIGNATURE: `main` takes no parameters — command-line arguments come from `runtime.args()` (§1-2.4.5, ruling MAIN-1)

The fix:

@mode(host.dual)

func main(): int {
    let argv = runtime.args();
    println(i"{$ argv.length() $} argument(s)");
    return 0;
}

Scripts can read their arguments the same way, or with the shorter args(). Chapter 18 builds a complete command-line tool.

Projects

A single script needs no project. When you want several source files, tests and a manifest, create a project:

loup-bootstrap init demo
cd demo

This writes:

demo/
├── demo.lppkg      # the package manifest
├── src/
│   └── main.loup   # the entry point, in host.dual
└── tests/          # empty; tests go here

The manifest is TOML. The generated one begins like this (it also contains several empty tables):

name = "demo"
version = "0.1.0"
loup = "1.20.0"
authors = []

[build]
entry = "src/main.loup"
delivery = "cli"
profile = "release"

And src/main.loup is a host.dual program:

@mode(host.dual)

func main(): void {
    println("hello, demo");
}

Inside the project, loup run src/main.loup runs it and loup-bootstrap check type-checks every file without running anything. Chapter 11 covers projects and packages; Chapter 16 covers tests.

Commands you will use

Command What it does
loup run file.lpscr [args…] Type-check and run a script (or a .loup program's main())
loup-bootstrap repl Start the REPL
loup-bootstrap init name Create a project
loup-bootstrap check Type-check the project in the current directory
loup-bootstrap test Run the project's tests (Chapter 16)
loup fmt file.lpscr Format a source file
loup-bootstrap build Build a project into a WebAssembly module

The Six Modes at a Glance

Mode Extension Entry point How it runs today Book
host.scripted .lpscr top-level statements, in order loup run (interpreter) this book
host.dual .loup main() loup run, or compiled to Wasm or native this book, Chapter 18; The Loup Native Book
host.native .lphc main() loup build --target native The Loup Native Book
web.scripted .lpweb browser bootstrap no browser runtime ships yet The Loup Web Book
web.compiled .lpweb browser bootstrap a bare .wasm module; no browser runtime yet The Loup Web Book
freestanding .lpfree an @entry function loup build --target freestanding (Linux) The Loup Freestanding Book

The language is the same in every mode; the mode decides which features and library modules are available and how the file is delivered. Every mode uses the same memory model — reference counting for class instances, copying for structs and collections — so code you write here keeps its meaning if you move it to host.dual or host.native.

A Roadmap of the Rest of the Book

Chapters Topic
2–5 Values and types, functions and closures, control flow, collections
6–9 Structs, enums and match, classes, interfaces and generics
10–12 Errors, modules and packages, strings and templates
13 Concurrency
14, 15, 17 Reflection, reactive UI and JavaScript interop (design previews: not in Loup 1.25)
16, 18, 19 Testing, building command-line tools, ownership and resources

The first twelve chapters are the core; read the rest as you need them.

Summary

  • A Loup script is a .lpscr file that starts with @mode(host.scripted); loup run type-checks it and then runs its top-level statements in order.
  • In Loup 1.25, loup run is an interpreter on your operating system, not a JavaScript or browser runtime.
  • Install with the official installers from code-craft-ai-company/loup-releases.
  • Comments start with #; // is floor division. There is no ternary operator; if is an expression.
  • Compile errors print one file:line:col: error[CODE]: message line each and exit 2; uncaught exceptions print Uncaught … with a stack trace and exit 1.
  • __name__ == "__main__" guards code that should run only when the file is run directly.
  • Scripts get fs, proc and entropy by default; --allow-net and the other flags change that.
  • A script that becomes a program moves to host.dual: a .loup file whose code lives in main().

Try It Yourself

  1. Write fizzbuzz.lpscr that prints FizzBuzz from 1 to 100. (for (i in 1..101) { … } loops over 1 to 100, and % is the remainder.)
  2. Move the logic into pub func fizzbuzz(n: int): string, guard the loop with if (__name__ == "__main__"), and import the function from a second script.
  3. Write let x: int = "hello"; and read the diagnostic. Then write x = 3; after let x = 1; and compare the two messages.
  4. Convert fizzbuzz.lpscr into fizzbuzz.loup in host.dual, and see which error you get if you forget to move the loop into main().