The Loup Native Book — contents
  1. Chapter 1: Getting started
  2. Chapter 2: Native mode overview
  3. Chapter 3: Variables and numerics
  4. Chapter 4: Functions
  5. Chapter 5: Control flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and pattern matching
  9. Chapter 9: Classes and ARC
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error handling
  13. Chapter 13: Modules and packages
  14. Chapter 14: Template fences
  15. Chapter 15: Ownership verbs
  16. Chapter 16: borrow, excl borrow and noescape
  17. Chapter 17: unique ownership
  18. Chapter 18: Lifetimes and regions
  19. Chapter 19: @unsafe and calling C
  20. Chapter 20: Concurrency with OS threads
  21. Chapter 21: Async on native
  22. Chapter 22: Desktop UI
  23. Chapter 23: SIMD and GPU
  24. Chapter 24: Building and distributing
  25. Chapter 25: Memory safety patterns

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:

This code does not compile
@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:

This code does not compile
@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 n from an int main exits with n's low byte (263 exits 7, -1 exits 255);
  • a void main exits 0;
  • an uncaught exception exits 1;
  • a compile error exits 2;
  • exit(code) ends the process at once with code.

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 returns void or int; arguments come from runtime.args(). Statements at the top level are E_TOPLEVEL_CONTROL_AOT.
  • loup run file.lphc runs a native-mode program on the interpreter on every platform; the book's listings are checked this way.
  • loup build --target native -o app builds an executable from a project with a .lppkg manifest. Linux x86-64 is supported (needs clang); 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 and main() is never called.
  • Chapter 2 explains what the two native modes allow and forbid, and how they compare to Loup's other modes.