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 13: Modules and packages

A program larger than one file is a package: a directory with a .lppkg manifest and source files, each of which is a module. This chapter covers how modules import each other and the standard library, what pub exposes, re-exports, the manifest, and LOUPPATH, the way to use a package from another directory. It also says plainly what Loup 1.25.1 cannot do yet: there is no package registry, so loup install cannot fetch dependencies.

Importing from the standard library

An import names a module path and a parenthesised list of names. as renames a name, and import path as alias binds the whole module:

@mode(host.native)

from core.math import (sqrt, floor as fl);
import core.math as m;

func main(): void {
    println(sqrt(2.0));
    println(fl(2.7));
    println(m.pow(2.0, 10.0));
}

Listing 13-1: Two forms of import

1.4142135623730951
2
1024

Imports are allowed only at module level, after the @mode line (and after a namespace line, if there is one). The prelude functions used throughout this book (println, readText, exec, env, …) need no import. docs/stdlib-status.md in the repository lists which standard-library modules ship in 1.25.1: among them core.math, core.time, core.random, io.fs, io.path, io.proc, system.json, system.text, system.regex, the data.* formats (csv, toml, yaml, xml, ini), net.tcp and the server half of net.http. Many specified modules, including sync.*, the native.* family and ui.*, do not exist yet.

Mistake: an import list without parentheses

Python writes from math import sqrt, floor. Loup requires the parentheses, even for one name:

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

from core.math import sqrt, floor;

func main(): void {
    println(sqrt(2.0));
}
main.lphc:3:23: error[E0001]: expected LParen, found Identifier
main.lphc:3:34: error[E0001]: expected RParen, found Semicolon

There is no wildcard import either: from core.math import *; adds error[E0007]: wildcard imports are not allowed. Name what you use, so that every name in a file can be traced to its source.

Modules in a package

Each source file is a module, named by its path relative to the source directory, without the extension. Here is a small stock-report program split into three modules and a manifest:

inventory/
├── inventory.lppkg
└── src/
    ├── main.lphc
    ├── stock.lphc
    ├── format.lphc
    └── api.lphc
name = "inventory"
version = "0.1.0"

[build]
entry = "src/main.lphc"
delivery = "native"

Listing 13-2: inventory.lppkg

src/stock.lphc defines the data and the logic. Declarations without pub stay private to the module:

@mode(host.native)

pub struct Item {
    pub sku: string;
    pub count: int;
}

pub func lowStock(items: list[Item], threshold: int): list[Item] {
    var result: list[Item] = [];
    for (item in items) {
        if (item.count < threshold) {
            result.push(item);
        }
    }
    return result;
}

func auditLog(): string {
    return "audit";
}

Listing 13-3: src/stock.lphc

src/format.lphc imports from it by module name:

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

from stock import (Item);

pub func line(item: Item): string {
    return i"{$ item.sku $}: {$ item.count $} left";
}

Listing 13-4: src/format.lphc

src/api.lphc is a barrel module: it re-exports names from the others with pub from, so the rest of the program has one place to import from (Language Reference §1-13.2). The leading dot makes the path relative to the current module:

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

pub from .stock import (Item, lowStock);
pub from .format import (line as formatLine);

Listing 13-5: src/api.lphc

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

from api import (Item, lowStock, formatLine);

func main(): void {
    let items = [
        Item(sku = "bolt-m6", count = 4),
        Item(sku = "nut-m6", count = 120),
        Item(sku = "washer-m6", count = 9),
    ];
    for (item in lowStock(items, 10)) {
        println(formatLine(item));
    }
}

Listing 13-6: src/main.lphc

(Listings 13-4 to 13-6 import other files of the package, so the book's single-file checker skips them; they were run as a project.) From the inventory directory:

loup run src/main.lphc
loup-bootstrap check
loup build --target native -o inventory
bolt-m6: 4 left
washer-m6: 9 left

loup run takes the entry file; check and build find the manifest in the current directory or the nearest parent, and build every module the entry reaches.

Mistake: forgetting pub on struct fields

Struct fields are private to their module unless marked pub (Language Reference §1-10.2.1). Inside stock.lphc that never shows. Remove the two pubs from Item's fields and format.lphc stops compiling:

src/format.lphc:6:17: error[E_FIELD_PRIVATE]: member 'sku' of struct 'Item' is private
src/format.lphc:6:33: error[E_FIELD_PRIVATE]: member 'count' of struct 'Item' is private

The constructor still accepts every field by name; privacy only governs reading and writing item.sku from another module. Class members follow the same rule (chapter 9).

Mistake: importing a private function

auditLog has no pub, so another module cannot import it:

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

from stock import (Item, auditLog);

func main(): void {
    println(auditLog());
}
src/main.lphc:3:26: error[E0001]: cannot import private symbol `auditLog` from module
  note: add `pub` to the declaration to make it importable
src/main.lphc:6:13: error[E0001]: undefined identifier `auditLog`

Cycles

Two modules that import each other are rejected:

src/main.lphc:3:1: error[E_CIRCULAR_IMPORT]: import cycle detected: …/src/a.lphc → …/src/b.lphc → …/src/a.lphc
  note: circular imports are not allowed; consider restructuring your modules

Move what both need into a third module that imports neither. The specification also offers from x defer import (T), which imports a type as a stub to break a cycle (Language Reference §1-13.10); reach for it only when the cycle cannot be designed away.

Things that do not work as specified

  • namespace a.b; is accepted after the @mode line, but imports still resolve by file path: a module in src/stock.lphc that declares namespace inventory.stock; is imported as stock, not inventory.stock. Keep namespaces equal to file paths, or leave them out.
  • Directory index modules. from report import (…) resolving to src/report/index.lphc works under loup run, but loup build and loup-bootstrap check report module 'report' not found (E-BUILD-DEP, E-CHECK-DEP). Use a file such as api.lphc for a barrel, as above.
  • Top-level initializers. The Language Reference allows only constant initializers at the top of an AOT-mode module. Stage0 accepts let started = runtime.args(); there without complaint. Compute runtime values in main() and pass them on.

The manifest

The manifest is a TOML file named <package-name>.lppkg (or package.lppkg). These are the keys a native project uses (Language Reference §1-13.3; Loup Packages Reference):

name = "inventory"
version = "0.1.0"
# minimum toolchain version
loup = "1.25.0"

[build]
# the module whose main() starts the program
entry = "src/main.lphc"
# what to build: native, cli (WebAssembly), desktop, ...
delivery = "native"
# mode for files that declare none (every file should declare one)
mode = "host.native"
# or "debug"
profile = "release"

[modes]
# per-file mode for files without a directive
"src/legacy.loup" = "host.dual"

[dependencies]
textkit = { path = "../textkit" }

Listing 13-7: The parts of a manifest a native project uses

Put comments on lines of their own. The 1.25.1 manifest reader does not accept a comment after a value on the same line: loup = "1.25.0" # minimum is reported as invalid patch version: '0" # minimum'.

delivery was called target before 1.21; both spellings are accepted, as are [delivery.X] and [target.X] tables. loup-bootstrap init writes a manifest with these keys and several empty tables (chapter 1).

Dependencies

[dependencies] takes a version constraint (textkit = "^0.2") or a local path ({ path = "…" }).

⚠ Planned. The package registry is not available in Loup 1.25.1. As soon as a manifest declares a dependency, loup install stops with "registry-based package installation is not yet available in this release", and there are no loup pkg, loup add or loup publish commands. A { path = … } dependency is honoured by loup build, but not by loup run or loup-bootstrap check. For local packages, use LOUPPATH.

LOUPPATH

LOUPPATH is a list of directories, separated by : (; on Windows), each of which contains packages (Language Reference §1-13.9, ruling PATH-1). A package named textkit lives in <root>/textkit/ with its own textkit.lppkg, and its modules are imported as textkit.<module>:

~/dev/loup-packages/            # a LOUPPATH root
└── textkit/
    ├── textkit.lppkg           # name = "textkit"
    └── slug.loup
@mode(host.dual)

pub func slugify(title: string): string {
    return title.trim().toLowerCase().replace(" ", "-");
}

Listing 13-8: textkit/slug.loup, a host.dual library module

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

from textkit.slug import (slugify);

func main(): void {
    println(slugify("  Hello Native World "));
}

Listing 13-9: Using the package from a native program

$ loup run src/main.lphc
src/main.lphc:3:6: error[E0001]: unresolved package import 'textkit/slug': searched package roots ['src'] for the package path 'textkit/slug'
$ LOUPPATH=~/dev/loup-packages loup run src/main.lphc
hello-native-world
$ LOUPPATH=~/dev/loup-packages loup build --target native -o blog

Without LOUPPATH the import is unresolved; with it, loup run and loup build find the package. (loup-bootstrap check ignores LOUPPATH in 1.25.1 and reports E-CHECK-DEP.) Writing library code as host.dual, as textkit does, lets scripts, WebAssembly programs and native programs all use it; a host.native program may import host.dual modules freely.

The standard library's namespaces (core, io, data, net, sync, native, ui, web, lang, debug, structures, concurrent, and the loup. prefix) are reserved: a package on LOUPPATH may not supply modules under them (E_IMPORT_RESERVED_NAMESPACE), though your own program's source tree may.

Summary

  • from path import (a, b as c); and import path as m;; the list always has parentheses, and there are no wildcards.
  • Each file is a module named by its path under the source directory; declarations and struct fields are private unless pub (cannot import private symbol, E_FIELD_PRIVATE).
  • pub from .module import (…) re-exports; keep barrels in ordinary files, because directory index modules work only under loup run.
  • Import cycles are E_CIRCULAR_IMPORT.
  • The manifest is TOML: [build] entry, delivery, mode, profile; [modes]; [dependencies].
  • No registry yet: loup install cannot fetch dependencies, and path dependencies work only with loup build. Use LOUPPATH roots for local packages.
  • Chapter 14 covers template fences.