Chapter 1: Getting started
This chapter gets you from an empty directory to a running native Loup program. You will install the toolchain, write a program with a main() function, run it with loup run, and then build it into a native executable with loup build --target native. Along the way you will meet the first two mistakes almost everyone makes when moving from scripts to native programs.
Native Loup is written in one of two modes. A file that starts with @mode(host.native) (extension .lphc) is compiled ahead of time to a platform binary. A file that starts with @mode(host.dual) (extension .loup) follows the same rules but can be delivered either way: run by the interpreter, compiled to WebAssembly, or compiled to a native binary. Chapter 2 explains when to choose which. Most examples in this book use host.native.
Installing Loup
Official builds of Loup are published on the code-craft-ai-company/loup-releases GitHub page. The installer picks the archive for your machine, checks it against the release's signed SHA256SUMS, unpacks it into ~/.loup (or %LOCALAPPDATA%\Programs\Loup on Windows), and puts its bin directory on your PATH. No administrator rights are needed.
# Linux and macOS
curl -fsSL https://github.com/code-craft-ai-company/loup-releases/releases/latest/download/loup-install.sh | sh
# Windows (PowerShell)
irm https://github.com/code-craft-ai-company/loup-releases/releases/latest/download/loup-install.ps1 | iex
To pin a version, pass it to the script (sh -s -- --version 1.25.1) or set LOUP_VERSION before running the installer. Then check the installation:
loup --version
What you can do with native code depends on the platform you are on:
| Platform | loup is |
Native binaries (--target native) |
|---|---|---|
| Linux x86-64 | the self-hosted toolchain (plus loup-bootstrap) |
Supported; needs clang installed |
| macOS 11+ (Apple silicon, Intel) | the bootstrap compiler, loup-bootstrap |
Preview: LLVM backend only; needs the Xcode Command Line Tools |
| Windows 10+ x86-64 | the bootstrap compiler | Not yet |
| Linux arm64 | the bootstrap compiler | Not yet |
Every platform can run native-mode programs with loup run, which executes them on Loup's interpreter. That is how every listing in this book was checked, so you can follow the whole book on any platform and build binaries where the toolchain supports it.
Hello, world
Create a file named hello.lphc:
@mode(host.native)
func main(): void {
println("hello, native world");
}
Listing 1-1: A native program
Run it:
loup run hello.lphc
hello, native world
Two lines in this file are new if you are coming from Loup scripts.
@mode(host.native) declares the file's mode. It must be the first token in the file; only comments may come before it. A directive further down is E_MODE_POSITION, and a second directive is E_MODE_DUPLICATE. Every source file should declare its mode (ruling MODE-2), and the extension follows the mode (ruling MODE-3): .lphc for host.native, .loup for host.dual.
func main(): void is the entry point. In a script, the file's top-level statements run in order. In host.native and host.dual there is no top-level execution: the runtime calls main() once the module is initialized, whether the program is a native binary or is being run by loup run (Language Reference §1-2.4.5, rulings ENTRY-1 and MODE-5).
Mistake: code at the top level
If you write a native file the way you would write a script, the compiler stops you:
@mode(host.native)
println("hello, native world");
main.lphc:3:1: error[E_TOPLEVEL_CONTROL_AOT]: E_TOPLEVEL_CONTROL_AOT: top-level control flow is not allowed in `host.native` — only declarations and constant initializers are permitted at module scope; the runtime invokes `main()` after module initialization (§1-2.4)
A native binary has no "run the file from top to bottom" step, so statements outside a function have nowhere to run. Only declarations (functions, classes, structs, enums, imports) and constant initializers may appear at module scope. Move the statement into main(), as in Listing 1-1.
Diagnostics from loup run have the shape file:line:column: error[CODE]: message, one per line, and the process exits with status 2. The spec's error code usually appears twice: once in the brackets and again at the start of the message.
Mistake: an old mode name
Earlier editions of this book used @mode(native). That name was retired in v1.17, but the toolchain does not reject it: a file with an unknown or retired mode name is quietly treated as a host.scripted script. A script does not call main() by itself, so the program prints nothing and exits with status 0:
$ loup run hello.lphc # the file says @mode(native)
$ echo $?
0
If a program's main() never seems to run, check the mode line first. The current names are host.native, host.dual, host.scripted, web.scripted, web.compiled and freestanding.
Arguments and exit status
main takes no parameters. Command-line arguments come from runtime.args(), and if main returns int, that value becomes the process exit status:
@mode(host.native)
func main(): int {
let args = runtime.args();
if (args.length() > 1) {
printErr("usage: greet [NAME]");
return 2;
}
var name = "world";
if (args.length() == 1) {
name = args[0];
}
println(i"hello, {$ name $}");
return 0;
}
Listing 1-2: Reading arguments and returning an exit status
$ loup run greet.lphc
hello, world
$ loup run greet.lphc Ada
hello, Ada
$ loup run greet.lphc Ada Grace
usage: greet [NAME]
$ echo $?
2
i"…{$ expr $}…" is an interpolated string; a plain "…" string is never interpolated. printErr writes to standard error.
A C or Rust programmer's first instinct is to declare the arguments as a parameter:
@mode(host.native)
func main(args: list[string]): int {
println(args);
return 0;
}
main.lphc: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)
Nothing passes main arguments, and only an int result is an exit status, so main must take no parameters and return void or int (ruling MAIN-1). The exit status rules are the same everywhere main is called (ruling EXIT-1):
return nfrom anint mainexits withn's low byte (263exits 7,-1exits 255);- a
void mainexits 0; - an uncaught exception exits 1;
- a compile error exits 2;
exit(code)ends the process at once withcode.
Building a native binary
loup run is for development. To produce an executable, build a project. loup build reads a package manifest, a TOML file named <name>.lppkg, so it always runs inside a project directory.
Create a project with the bootstrap compiler's init command:
loup-bootstrap init hello
cd hello
This writes hello/hello.lppkg, an empty tests/ directory, and src/main.loup, a host.dual program:
@mode(host.dual)
func main(): void {
println("hello, hello");
}
Listing 1-3: The scaffold's src/main.loup
A host.dual program can be built into a native binary as it is. For a host.native project, rename the file to src/main.lphc, change its first line to @mode(host.native), and point the manifest at it:
name = "hello"
version = "0.1.0"
loup = "1.20.0"
[build]
entry = "src/main.lphc"
delivery = "native"
profile = "release"
Listing 1-4: The [build] table of a native project
delivery names what the build produces (--target on the command line is the same setting, and target is accepted in the manifest as its older spelling). The scaffold's delivery = "cli" builds WebAssembly; "native" builds a platform executable. Now build and run:
loup build --target native -o hello
./hello
hello, hello
On Linux x86-64 this produces an ELF executable linked against the system C library; on macOS (preview) a Mach-O executable linked by Apple's clang and ld64. Nothing else needs to be installed on the machine that runs it: the Loup runtime (reference counting, strings, collections, exceptions) is compiled into the binary.
loup run and the native binary run the same program, but not on the same engine. loup run uses an interpreter, so a few runtime behaviours differ until the implementations converge. The book points these out where they come up; the first one you might notice is that an uncaught exception prints the exception and a stack trace under loup run, but a native binary prints a single line, such as ValueError: bad input, on standard error. Both exit with status 1.
Build options you will use
loup build --help lists every option. These are the ones that matter for native programs:
| Option | Meaning |
|---|---|
--target native |
Build a platform executable (overrides the manifest's delivery) |
-o PATH |
Where to write the executable |
--release, --debug |
Shorthands for --profile release / --profile debug |
--backend llvm|cranelift|llbe |
Code generator; llvm is the default |
--lto thin|full |
Link-time optimization (LLVM backend only) |
--mode MODE |
Project-wide mode for files that do not declare one |
--no-cache, --clean |
Force a full rebuild / delete the .loup-cache directory |
The LLVM backend writes LLVM IR and compiles it with clang, which is why Linux needs clang installed and macOS needs the Xcode Command Line Tools. Cranelift writes ELF objects only, so it is refused on macOS:
error[E-BUILD-NATIVE]: --backend cranelift writes ELF objects, which a macOS host cannot link; use the default LLVM backend (`--backend llvm`)
Chapter 24 covers the backends, cross-compilation, library output, and distribution in detail.
Checking a project without running it
loup-bootstrap check type-checks a whole project without building or running it, and prints diagnostics with source snippets:
loup-bootstrap check
It works on projects only: run it on a lone file and it stops with E-CHECK-MANIFEST. For single files, loup run reports the same compile errors (in the one-line format shown above) before it runs anything.
Summary
- Native Loup files declare
@mode(host.native)(.lphc) or@mode(host.dual)(.loup) as their first token. - Execution starts in
main(), which takes no parameters and returnsvoidorint; arguments come fromruntime.args(). Statements at the top level areE_TOPLEVEL_CONTROL_AOT. loup run file.lphcruns a native-mode program on the interpreter on every platform; the book's listings are checked this way.loup build --target native -o appbuilds an executable from a project with a.lppkgmanifest. Linux x86-64 is supported (needsclang); macOS is a preview on the LLVM backend; Windows and Linux arm64 cannot build native binaries yet.- A retired mode name such as
@mode(native)is not an error: the file silently runs as a script andmain()is never called. - Chapter 2 explains what the two native modes allow and forbid, and how they compare to Loup's other modes.