The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  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 Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

Chapter 13: Modules and Packages

What you'll learn. How files become modules, the from … import (…) and import … as … forms, pub visibility across files, barrel modules with pub from, the import rule between web.compiled and everything else, circular imports and defer import, the .lppkg manifest that loup-bootstrap init writes, and what the package tooling can and cannot do in Loup 1.25.

Why it matters. Up to now every program has been one file. Real projects are split across files and directories, and in the web modes the import rule decides which code can reach the standard library at all.

A project with more than one file

Start with the scaffold from Chapter 1 and give it a second directory. Here is a small shop project with a cart package of two modules:

shop/
  shop.lppkg
  src/
    main.lpweb
    cart/
      money.lpweb
      discount.lpweb
  tests/

Every source file is a module. Its import path is its location under src/, with directories joined by dots and the extension dropped: src/cart/money.lpweb is the module cart.money. Here is that module:

@mode(web.compiled)

pub struct Money {
    pub cents: int;
}

pub func format(m: Money): string {
    let rest = m.cents % 100;
    let dollars = (m.cents - rest) / 100;
    let pad = if (rest < 10) { "0" } else { "" };
    return "$" + str(dollars) + "." + pad + str(rest);
}

func checkCents(cents: int): void {
    if (cents < 0) {
        throw ValueError("negative amount");
    }
}

pub func dollars(d: int, c: int): Money {
    checkCents(c);
    return Money(cents = d * 100 + c);
}

Listing 13-1: src/cart/money.lpweb, a module with three public names and one private helper

The module is declarations only, as every web.compiled file must be (Chapter 2). pub marks what other modules may import: the struct Money, its field cents, and the functions format and dollars. checkCents has no pub, so it is private to this file. Struct and class fields are private to their module unless they are pub too; without pub cents, code in another module could build a Money but not read price.cents (error[E_FIELD_PRIVATE]: member 'cents' of struct 'Money' is private).

The second module uses a relative import. A leading dot means "next to this file":

Fragment — not a complete program
@mode(web.compiled)

from .money import (Money);

pub func applyPercent(m: Money, percent: int): Money {
    let off = m.cents * percent / 100;
    return Money(cents = m.cents - off);
}

Listing 13-2: src/cart/discount.lpweb imports a sibling module with a relative path

The entry file imports from both:

Fragment — not a complete program
@mode(web.compiled)

from cart.money import (Money, format, dollars);
from cart.discount import (applyPercent);

func main(): void {
    let price = dollars(19, 99);
    let sale = applyPercent(price, 15);
    println(format(price) + " -> " + format(sale));
}

Listing 13-3: src/main.lpweb imports from two modules of the project

Listings 13-2 and 13-3 are marked ignore only because the book's listing checker compiles one file at a time; the project as a whole was run with the 1.25.1 toolchain:

$ loup run src/main.lpweb
$19.99 -> $17.00

A web.compiled module can import another web.compiled module, both under loup run and in a Wasm build. loup-bootstrap build --target browser compiles all three modules into one build/shop.wasm, which loup-launcher runs with the same output:

$ loup-bootstrap build --target browser
Resolving dependencies...
Compiling 3 module(s)...
Generating code...
Build complete: …/shop/build/shop.wasm (21953 bytes)
…/shop/build/shop.wasm
$ loup-launcher --run-bare build/shop.wasm
$19.99 -> $17.00

Importing a private name

If main.lpweb reaches for the private helper:

Fragment — not a complete program
@mode(web.compiled)

from cart.money import (dollars, checkCents);

func main(): void {
    checkCents(-1);
    println(dollars(1, 0).cents);
}

the compiler refuses the import and every use of the name:

src/main.lpweb:3:34: error[E0001]: cannot import private symbol `checkCents` from module
  note: add `pub` to the declaration to make it importable
src/main.lpweb:6:5: error[E0001]: undefined identifier `checkCents`

The fix is the one the note gives — add pub to checkCents — or, better here, leave it private: dollars already calls it, and callers outside the module have no reason to.

Loup has two levels of visibility: pub (importable) and private (the declaring module only). There is no pub(package) or pub(module); writing one is a parse error.

The import forms

All imports go at the top of the file, after @mode(...) and an optional namespace line. They cannot appear inside a function. Wildcard imports (import *) do not exist: every imported name is written out.

Form Binds
from cart.money import (Money, format); the names Money and format
from cart.money import (format as formatMoney); formatMoney only
from .money import (Money); a name from a sibling module
from cart { money import (Money); discount import (applyPercent); } grouped imports under one prefix
import cart.money as money; the module, used as money.format(…)
import cart.money; the module under its full path, cart.money.format(…)

The import list always has parentheses, even for one name. The next listing is a web.scripted file, which can import the standard library (more on that below), and it leaves the parentheses out:

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

from core.math import sqrt, floor;

println(sqrt(16.0));
println(floor(2.7));
main.lpweb:3:23: error[E0001]: expected LParen, found Identifier
main.lpweb:3:34: error[E0001]: expected RParen, found Semicolon

"Expected LParen" points at sqrt: the parser wanted ( after import. Add the parentheses:

@mode(web.scripted)

from core.math import (sqrt, floor);

println(sqrt(16.0));
println(floor(2.7));

Listing 13-4: An import list is always parenthesized

4
2

An alias replaces the name

as binds the imported symbol under the new name instead of the original. A reader who renames sqrt and then keeps using sqrt gets:

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

from core.math import (sqrt as root);

println(sqrt(16.0));
main.lpweb:5:9: error[E0001]: undefined identifier `sqrt`

Use the alias, or import the name twice if you want both spellings (from core.math import (sqrt as root, sqrt);):

@mode(web.scripted)

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

println(root(16.0));
println(floor(2.7));
4
2

Importing a module

import path as name; binds the whole module. Its public names are reached through the alias, which keeps it obvious at each call site where a name comes from:

@mode(web.scripted)

import core.math as math;

let r = 3.0;
println(math.PI * r * r);
println(math.sqrt(2.0));

Listing 13-5: A module import, with members reached as math.PI and math.sqrt

28.274333882308138
1.4142135623730951

A module alias names a scope, not a value: math on its own is an error, and only the module's pub names are members of it.

Barrel modules with pub from

A directory can have an index.lpweb that re-exports what the rest of the program should use. Importing the directory's path (from cart import …) finds that index:

Fragment — not a complete program
@mode(web.compiled)

pub from .money import (Money, format, dollars);
pub from .discount import (applyPercent);

Listing 13-6: src/cart/index.lpweb, a barrel module

Callers no longer need to know which file declares what (this listing and the barrel are run as part of the project, like Listing 13-3):

Fragment — not a complete program
@mode(web.compiled)

from cart import (dollars, format, applyPercent);

func main(): void {
    let sale = applyPercent(dollars(19, 99), 15);
    println(format(sale));
}
$17.00

A re-export can rename (pub from .money import (format as formatMoney);), and it can only re-export pub names. Re-exporting checkCents from the barrel fails in the barrel itself with error[E0001]: cannot re-export private symbol checkCents from module … and the note only pub symbols can be re-exported.

namespace declarations

A file may declare a namespace right after its @mode line:

@mode(web.compiled)

namespace shop.cart;

pub func lineTotal(unitCents: int, qty: int): int {
    return unitCents * qty;
}

func main(): void {
    println(lineTotal(450, 3));
}
1350

The Language Reference (§1-13.0) says a file's public names are exposed under its namespace path. In Loup 1.25 the resolver still finds modules by file path only: with that line in src/cart/pricing.lpweb, from cart.pricing import (…) works and from shop.cart import (…) fails with unresolved package import. Until that changes, either leave namespace out or make it match the file's path.

The import rule for web.compiled

A web.compiled module may import only other web.compiled modules (Language Reference §3-1.2). Every module of the standard library is a host.scripted module, so in Loup 1.25 a web.compiled file cannot import any of them. The prelude — println, str, repr, Ok, Some, the exception classes and the other names Chapter 2 lists — needs no import and is available everywhere.

This is the mistake you will make first:

This code does not compile
@mode(web.compiled)

from core.math import (sqrt);

func main(): void {
    println(sqrt(2.0));
}
main.lpweb:3:1: error[E0001]: `@mode(web.compiled)` module cannot import `@mode(host.scripted)` module
  note: `@mode(web.compiled)` modules can only import other `@mode(web.compiled)` modules
  note: error code: E_COMPILED_IMPORT_SCRIPTED

The code is E_COMPILED_IMPORT_SCRIPTED, the third line says so. There are two ways out. If the program needs the library module, write it in web.scripted, which may import the standard library and runs its top-level statements in order (no main):

@mode(web.scripted)

from core.math import (sqrt);

println(sqrt(2.0));

Listing 13-7: The same program in web.scripted, which can import core.math

1.4142135623730951

If the program must stay web.compiled, keep library calls out of it, or write the small piece you need yourself in a web.compiled module of your own.

The same rule applies to your own files. If src/cart/tax.lpweb is a web.scripted module, importing it from main.lpweb (a web.compiled module) fails with the same code:

src/main.lpweb:3:1: error[E0001]: `@mode(web.compiled)` module cannot import `@mode(web.scripted)` module
  note: `@mode(web.compiled)` modules can only import other `@mode(web.compiled)` modules
  note: error code: E_COMPILED_IMPORT_SCRIPTED

The other direction is allowed. A web.scripted file can import your web.compiled modules and the standard library, which makes it the natural home for a tool that glues the two together. This one reads a price list with data.csv and prints it with the cart package:

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

from data.csv import (parse);
from cart import (dollars, format, applyPercent);

let rows = parse("tea,4,50\nmug,12,0", ",");
for (row in rows) {
    let price = dollars(row[1].to_int(), row[2].to_int());
    let sale = applyPercent(price, 10);
    println(row[0] + ": " + format(price) + " (sale " + format(sale) + ")");
}

Listing 13-8: src/pricelist.lpweb, a web.scripted tool that imports both the project's web.compiled modules and the standard library

$ loup run src/pricelist.lpweb
tea: $4.50 (sale $4.05)
mug: $12.00 (sale $10.80)
   web.scripted ───────────► web.compiled     allowed
   web.scripted ───────────► standard library allowed
   web.compiled ───────────► web.compiled     allowed
   web.compiled ─ ─ ─ ─ ─ ─► web.scripted     E_COMPILED_IMPORT_SCRIPTED
   web.compiled ─ ─ ─ ─ ─ ─► standard library E_COMPILED_IMPORT_SCRIPTED (1.25)

⚠ Planned. The Language Reference describes the browser modules (web.dom, web.storage, web.workers, ui.web, lang.interop.js, …) and says web code gets the full standard library apart from host capabilities. None of the web.* or ui.* modules ships in Loup 1.25 — chapters 16–18 describe them as design previews.

Reserved namespaces

The roots core, io, data, net, sync, concurrent, web, ui, native, lang, debug and structures, and the loup. prefix, are reserved for the standard library and first-party packages (Language Reference §1-13.6). An import under a reserved root must name a module the standard library actually has (or one your own src/ tree supplies). Importing a planned browser module therefore fails even though the name is "right":

This code does not compile
@mode(web.compiled)

from web.dom import (query);

func main(): void {
    println("ready");
}
main.lpweb:3:6: error[E0001]: 'web.dom' is not a standard-library module (namespace 'web' is reserved for the standard library)

There is no fix in 1.25 other than not using the module; the list of modules that ship is in the standard library status page of the Loup repository (docs/stdlib-status.md).

Circular imports and defer import

Two modules that import each other form a cycle, and the compiler rejects it in every mode (E_CIRCULAR_IMPORT, Language Reference §1-13.10). An employee knows its team and a team lists its employees:

Fragment — not a complete program
@mode(web.compiled)

from org.team import (Team);

pub class Employee {
    pub let name: string;
    pub var team: Option[Team] = None;

    new(name: string) {
        this.name = name;
    }
}

Listing 13-9: src/org/employee.lpweb imports Team

Fragment — not a complete program
@mode(web.compiled)

from org.employee import (Employee);

pub class Team {
    pub let title: string;
    pub var members: list[Employee] = [];

    new(title: string) {
        this.title = title;
    }
}

Listing 13-10: src/org/team.lpweb imports Employee

A program that imports either module stops at the cycle (paths shortened from the absolute paths the compiler prints):

src/staff.lpweb:3:1: error[E_CIRCULAR_IMPORT]: import cycle detected: src/org/team.lpweb → src/org/employee.lpweb → src/org/team.lpweb
  note: circular imports are not allowed; consider restructuring your modules

The usual fixes are to move the shared types into a third module that both import, or to merge the two modules. When the two types really do refer to each other, one side can import the other's name as a deferred type stub: change the first line of employee.lpweb to

Fragment — not a complete program
from org.team defer import (Team);

defer import gives Team a type identity at once and binds its definition after module initialization, which breaks the cycle. With that one change this program runs:

Fragment — not a complete program
@mode(web.compiled)

from org.team import (Team);
from org.employee import (Employee);

func main(): void {
    let web = Team("Web");
    let ann = Employee("Ann");
    safe {
        ann.team = Some(web);
        web.members.push(ann);
    }
    println(web.title + " has " + str(web.members.len()) + " member(s)");
    match ann.team {
        Some(t) => println(ann.name + " is on " + t.title),
        None => println(ann.name + " has no team"),
    }
}

Listing 13-11: src/staff.lpweb uses both classes once the cycle is broken with defer import

Web has 1 member(s)
Ann is on Web

(The safe { … } block is there because writing a var field of a class instance needs a safe context; Chapter 9 explains why.) Listings 13-9 to 13-11 are marked ignore because they span several files; they were run together as a project with the 1.25.1 toolchain. A deferred cycle is still a hint that the modules might be split differently.

Module dunders

Every module can read three string constants: __name__, __file__ and __execmode__. They are handy in log lines:

@mode(web.compiled)

func whereAmI(): string {
    return __name__ + " in " + __file__ + " (" + __execmode__ + ")";
}

func main(): void {
    println(whereAmI());
}
__main__ in main.lpweb (interpreted)

Do not branch on these values yet. The Language Reference says that in the web modes __name__ is the module's dotted name and never "__main__", and that __execmode__ is the mode string ("web.compiled"). Under loup run in 1.25 __name__ is "__main__", __execmode__ is "interpreted", and all three report the entry file's values even inside an imported module: moved into src/cart/locate.lpweb and called from src/d.lpweb, whereAmI() prints __main__ in src/d.lpweb (interpreted).

The package manifest

A project is a directory with a .lppkg manifest. loup-bootstrap init writes one (Chapter 1 explains why this book uses loup-bootstrap for init, check and build):

$ loup-bootstrap init shop
created project at …/shop

It creates shop/shop.lppkg, shop/src/main.loup and an empty shop/tests/. The scaffold is a host.dual program; for a web project, rename src/main.loup to src/main.lpweb, change its first line to @mode(web.compiled), and point entry at it. The top of the manifest then reads:

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

[build]
entry = "src/main.lpweb"
delivery = "cli"
profile = "release"
strict_deps = false
separate = false

loup-bootstrap init also writes empty [test], [dependencies], [dev_dependencies], [delivery], [stdlib], [modes], [linker], [freestanding], [options], [const_eval] and [exports] tables, which you can leave alone. The keys you will touch:

  • name, version — the package's name (lowercase, hyphen-separated segments) and its semantic version. loup is the minimum toolchain version.
  • [build] entry — the file loup-bootstrap build starts from.
  • [build] delivery — the artifact shape: cli by default, browser or wasm for a web module. loup-bootstrap build --delivery browser and its older spelling loup-bootstrap build --target browser override it; manifests may likewise still say target for delivery and [target.X] for [delivery.X]. Today every one of these shapes produces a bare build/<name>.wasm (Chapter 1).
  • [modes] and [build] mode — project defaults for files that declare no mode. Ruling MODE-2 says every file should declare its own mode, so you should not need them.

The full schema is in the Language Reference §1-13.3.

Dependencies and LOUPPATH

⚠ Planned. The package registry is not available in Loup 1.25. loup add, loup pkg, loup publish and loup link do not exist (error: unknown command 'add'), loup.lock is never written, and [dev-overrides] and the Core Package Set (loup.http, loup.db, …) are described in the Language Reference and the Packages Reference but not usable.

Declaring a registry dependency in [dependencies] makes loup install stop with:

error: 1 external dependency declared, but registry-based package installation is not yet available in this release
  note: the package registry and the `loup install`/`add`/`publish` network paths are planned; see the Packages Reference

What does work is LOUPPATH. It is a colon-separated list (semicolons on Windows) of roots: directories that contain packages. A package named acme.units lives at <root>/acme/units/ beside its manifest acme.units.lppkg:

/home/you/vendor/            # a root on LOUPPATH
  acme/                      # namespace directory, no manifest
    units/
      acme.units.lppkg       # name = "acme.units"
      length.lpweb           # module acme.units.length
@mode(web.compiled)

pub func cmToInches(cm: float): float {
    return cm / 2.54;
}

Listing 13-12: length.lpweb in the acme.units package

With the root on LOUPPATH, from acme.units.length import (cmToInches); resolves from the shop project:

$ LOUPPATH=/home/you/vendor loup run src/main.lpweb
10

Without it, the same run fails with unresolved package import 'acme/units/length': searched package roots ['src'] …. In 1.25 loup run and loup-bootstrap build honour LOUPPATH; loup-bootstrap check does not yet (it reports E-CHECK-DEP). A path dependency in [dependencies] ("acme.units" = { path = "../vendor/acme/units" }) is the reverse: loup-bootstrap build uses it and loup run does not. Prefer LOUPPATH for now.

prefer compiled

The Language Reference describes module fallback pairs (§1-13.7): a package ships a compiled artifact and a scripted source for the same module, and an import written from codec import (Encoder) prefer compiled; loads the compiled one when it can. In web.compiled the clause is meant to be redundant (the spec gives it a prefer-compiled-redundant lint).

⚠ Planned. Loup 1.25 parses prefer compiled and ignores it — no lint, no fallback search, no .wasm artifact lookup. Write plain imports.

A project layout

Putting the chapter together:

shop/
  shop.lppkg               # manifest: [build] entry = "src/main.lpweb"
  src/
    main.lpweb             # web.compiled entry, func main()
    pricelist.lpweb        # web.scripted tool: imports cart + data.csv
    staff.lpweb            # web.compiled
    cart/
      index.lpweb          # barrel: pub from .money / .discount
      money.lpweb
      discount.lpweb
    org/
      employee.lpweb       # from org.team defer import (Team)
      team.lpweb
  tests/
  build/                   # loup-bootstrap build output: shop.wasm

src/ holds the program's modules; everything in it is found by path. Files that need the standard library are web.scripted; everything that will be compiled to the Wasm module is web.compiled and imports only other web.compiled files.

Summary

  • Every source file is a module whose import path is its location under src/. Imports are module-level, list every name in parentheses, and come in from … import (…), relative (from .money), grouped, and import … as m forms. An alias replaces the original name.
  • pub makes a declaration, or a struct/class field, importable; everything else is private to its file. There is no pub(package).
  • pub from .x import (…) builds barrel modules; only pub names can be re-exported.
  • A web.compiled module imports only web.compiled modules (E_COMPILED_IMPORT_SCRIPTED). In 1.25 that excludes the whole standard library; use web.scripted for code that needs it. web.scripted may import both.
  • web.* and ui.* modules are planned; importing one is an error today.
  • Import cycles are E_CIRCULAR_IMPORT; defer import breaks a cycle between types.
  • loup-bootstrap init writes the .lppkg manifest; [build] entry and delivery are the keys a web project changes. The registry, loup add, loup.lock and prefer compiled resolution are planned; LOUPPATH works with loup run and loup-bootstrap build.