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 runworks 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 runtype-checks the program and then runs it on an interpreter built into theloupbinary. 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 ashost.scripted— so a wrong name in a script goes unnoticed, while a wrong name in a program means itsmain()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 onestringparameter that returns astring. Parameter types and the return type are written after a colon. Chapter 3 covers functions.i"Hello, {$ name $}!"is an interpolated string. Theiprefix 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. Usevarfor 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:
@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
intis a 64-bit signed integer. Chapter 2 covers the other numeric types.- The condition of an
ifis always written in parentheses, and the branches are always blocks in braces. ifis 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:
@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:
@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:
@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:
@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 runcompiles the entry script and its imports as one program: an imported file's declarations andletinitializers take effect, but its other top-level statements (such as aprintlnor 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 isloupon macOS, Windows and Linux arm64. The self-hostedloupon Linux x86-64 grants every capability and ignores the--allow-*and--deny-allflags; useloup-bootstrap runthere 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:
@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:
@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
.lpscrfile that starts with@mode(host.scripted);loup runtype-checks it and then runs its top-level statements in order. - In Loup 1.25,
loup runis 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;ifis an expression. - Compile errors print one
file:line:col: error[CODE]: messageline each and exit 2; uncaught exceptions printUncaught …with a stack trace and exit 1. __name__ == "__main__"guards code that should run only when the file is run directly.- Scripts get
fs,procandentropyby default;--allow-netand the other flags change that. - A script that becomes a program moves to
host.dual: a.loupfile whose code lives inmain().
Try It Yourself
- Write
fizzbuzz.lpscrthat prints FizzBuzz from 1 to 100. (for (i in 1..101) { … }loops over 1 to 100, and%is the remainder.) - Move the logic into
pub func fizzbuzz(n: int): string, guard the loop withif (__name__ == "__main__"), and import the function from a second script. - Write
let x: int = "hello";and read the diagnostic. Then writex = 3;afterlet x = 1;and compare the two messages. - Convert
fizzbuzz.lpscrintofizzbuzz.loupinhost.dual, and see which error you get if you forget to move the loop intomain().