The Loup Scripting Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: Variables and Data Types
  3. Chapter 3: Functions and Closures
  4. Chapter 4: Control Flow
  5. Chapter 5: Collections
  6. Chapter 6: Structs
  7. Chapter 7: Enums and Pattern Matching
  8. Chapter 8: Classes and Objects
  9. Chapter 9: Interfaces and Generics
  10. Chapter 10: Error Handling
  11. Chapter 11: Modules and Packages
  12. Chapter 12: Strings, Interpolation, and Template Fences
  13. Chapter 13: Concurrency
  14. Chapter 14: Reflection and Dynamic Compilation
  15. Chapter 15: Reactive UI
  16. Chapter 16: Testing
  17. Chapter 17: JavaScript Host Interop
  18. Chapter 18: Building Script Tools
  19. Chapter 19: Memory Safety and Resource Management

Chapter 11: Modules and Packages

What you'll learn: how one script uses code from another file, how pub decides what a file exports, the import forms Loup has (and the ones it deliberately lacks), what happens when a module is imported, how a project is laid out, and what the 1.25 toolchain offers for sharing packages.

Every File Is a Module

Any Loup source file can be imported by another. The import path is the file's path relative to the importing script's directory, with / written as . and the extension left off. With this layout:

wordtools/
├── main.lpscr
└── textutil/
    ├── slug.lpscr
    └── stats.lpscr

main.lpscr imports textutil/slug.lpscr as textutil.slug. Inside a project (later in this chapter), paths are relative to the project's src/ directory instead.

Exporting with pub

Declarations are private to their file unless they are marked pub:

@mode(host.scripted)

## Lower-case `text` and join its words with hyphens.
pub func slugify(text: string): string {
    let words = text.trim().toLowerCase().split(" ");
    let kept = words.filter(func(w: string) => w.length() > 0);
    return kept.join("-");
}

## Not exported: only this file can call it.
func isSeparator(c: string): bool {
    return c == " " or c == "-";
}

Listing 11-1: textutil/slug.lpscr exports one function

pub works on functions, classes, structs, enums, interfaces and module-level let bindings. There is no export keyword and no private: a name is either pub or file-private.

@mode(host.scripted)

## Number of space-separated words in `text`.
pub func wordCount(text: string): int {
    return text.split(" ").filter(func(w: string) => w.length() > 0).length();
}

Listing 11-2: textutil/stats.lpscr

Importing Names

from module import (names); binds the listed names in the importing file. as renames one:

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

from textutil.slug import (slugify);
from textutil.stats import (wordCount as countWords);

let title = "  Error Handling in Loup  ";
println(slugify(title));
println(countWords(title));

Listing 11-3: main.lpscr imports from two sibling modules (a multi-file example)

loup run main.lpscr
error-handling-in-loup
4

The alias replaces the original name: after wordCount as countWords, only countWords is bound.

Two other forms are useful. import module as name; binds the whole module, and you reach its exports through the name. A grouped import shares a prefix:

@mode(host.scripted)

import core.math as m;
from core {
    math import (sqrt, floor as roundDown);
}

println(m.sqrt(2.0));
println(sqrt(16.0));
println(roundDown(2.7));

Listing 11-4: A module import and a grouped import

1.4142135623730951
4
2

(sqrt(16.0) is the float 4.0; Loup prints a whole-number float without the .0.)

Imports may appear only at the top level of a file, never inside a function or block.

Mistake: an import list without parentheses

Python writes from math import sqrt, floor. Loup requires the parentheses:

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

from core.math import sqrt;

println(sqrt(2.0));
main.lpscr:3:23: error[E0001]: expected LParen, found Identifier
main.lpscr:3:27: error[E0001]: expected RParen, found Semicolon

Write from core.math import (sqrt); — parentheses even around a single name.

Mistake: a wildcard import

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

from core.math import *;

println(sqrt(2.0));
main.lpscr:3:23: error[E0001]: expected LParen, found Star
main.lpscr:3:23: error[E0007]: wildcard imports are not allowed
main.lpscr:3:24: error[E0001]: expected RParen, found Semicolon

Every imported name is listed, so a reader can always see where a name came from. If a list grows long, import the module instead: import core.math as m;.

Mistake: importing a private name

If main.lpscr asks textutil/slug.lpscr for its private helper:

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

from textutil.slug import (isSeparator);

println(isSeparator(" "));
main.lpscr:3:28: error[E0001]: cannot import private symbol `isSeparator` from module
  note: add `pub` to the declaration to make it importable
main.lpscr:5:9: error[E0001]: undefined identifier `isSeparator`

The note says what to do if the helper should be public. Often the better fix is to leave it private and use the public function that wraps it.

Re-Exports and Directory Modules

A directory can have an index module that gathers its contents into one import. pub from … import (…) re-exports names, optionally renamed:

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

pub from .slug import (slugify);
pub from .stats import (wordCount as countWords);

Listing 11-5: textutil/index.lpscr re-exports from its sibling files

Importing the directory itself now finds the index:

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

from textutil import (slugify, countWords);

println(slugify("Modules and Packages"));
println(countWords("Modules and Packages"));
modules-and-packages
3

The leading dot in from .slug makes the path relative to the file that contains the import. Relative imports keep a directory of modules movable: rename textutil/ and its index still works.

Only pub names can be re-exported (E_REEXPORT_NOT_PUB, §1-13.2). Rust's pub use and JavaScript's export … from do not exist in Loup; pub from is the one re-export form.

What Runs When a Module Is Imported

@mode(host.scripted)

println("loading settings");

pub let defaultPort: int = 8080;
pub let defaultHost: string = "localhost";
pub let baseUrl: string = i"http://{$ defaultHost $}:{$ defaultPort $}";

if (__name__ == "__main__") {
    println(i"settings self-check: {$ baseUrl $}");
}

Listing 11-6: settings.lpscr, a module with a self-check

Run directly, every top-level statement runs:

loading settings
settings self-check: http://localhost:8080

Imported by another script:

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

from settings import (baseUrl, defaultPort);

println(i"serving {$ baseUrl $} (port {$ defaultPort $})");
serving http://localhost:8080 (port 8080)

The imported module's let initializers ran — baseUrl has its value — but loading settings was not printed.

Module initialization today. The Language Reference (§1-13.4) says that importing a module runs all of its top-level statements once, in order, with __name__ set to the module's dotted name. In Loup 1.25, loup run compiles the entry script and everything it imports as a single program: an imported module's declarations and let/var initializers take effect, but its other top-level statements do not run, and __name__ is "__main__" in every module. The main guard still behaves as intended (the guarded block runs only in the file you run), but do not rely on an imported module's top-level statements for setup: put setup in a function and call it.

Give exported let bindings an explicit type, as Listing 11-6 does. Without one, an importer may be told type of 'defaultPort' is not yet known.

Namespaces

A file may declare the dotted path it represents, right after its @mode line:

@mode(host.scripted)
namespace textutil.slug;

pub func slugify(text: string): string {
    let words = text.trim().toLowerCase().split(" ");
    let kept = words.filter(func(w: string) => w.length() > 0);
    return kept.join("-");
}

The declaration should match the file's location (§1-13.0); tools treat a mismatch as a lint warning. In the 1.25 toolchain, loup run still finds a module by its file path, so the declaration documents the path rather than changing it. Most scripts leave it out.

Circular Imports

Two modules that import each other are an error:

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

(The message prints the full paths; they are shortened here.) Usually the fix is to move what both need into a third module. When two types genuinely refer to each other, one side can use defer import, which gives the name a type immediately and binds its definition later (§1-13.10):

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

from edges defer import (Edge);

pub class Node {
    pub let name: string;
    pub var edges: list[Edge] = [];

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

Listing 11-7: nodes.lpscr breaks the cycle with defer import

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

from nodes import (Node);

pub class Edge {
    pub let src: Node;
    pub let dst: Node;

    new(src: Node, dst: Node) {
        this.src = src;
        this.dst = dst;
    }
}

Listing 11-8: edges.lpscr imports Node normally

A script that imports both can build a graph: Edge(Node("a"), Node("b")).

prefer compiled

⚠ Planned. from codec import (Encoder) prefer compiled; asks the runtime to load a compiled build of the module when one exists and fall back to the source otherwise; the declaration's __module__.__execmode__ would then report which happened (§1-13.7). The 1.25 toolchain accepts the clause but always uses the source, and __module__ is not implemented.

The Standard Library

The standard library is imported like any other module. These namespaces ship in 1.25:

Namespace Modules
system json, text, regex, collections, env, time (the prelude — println, readText, args, … — is available without an import)
core math, time, datetime, random, uuid, string_utils, shlex, waiting, complex, array, …
io fs, path, proc, streams
data csv, ini, toml, xml, yaml, query
net tcp, addressing, http (a server; there is no HTTP client yet)
structures case_insensitive_dict, ordered_set, priority_queue
debug assert, profiler

Some module names you may expect do not exist. JSON lives in system.json, not data.json:

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

from data.json import (parse);

println(parse("[1, 2]"));
main.lpscr:3:6: error[E0001]: 'data.json' is not a standard-library module (namespace 'data' is reserved for the standard library)
main.lpscr:5:9: error[E0001]: undefined identifier `parse`
@mode(host.scripted)

from system.json import (parse, stringify);

let doc = parse("{\"name\": \"loup\", \"tags\": [\"a\", \"b\"]}").unwrap();
println(stringify(doc));
{"name":"loup","tags":["a","b"]}

The namespaces 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 (§1-13.6). Your package cannot be named after one of them. system is not reserved. The Language Reference describes more modules than ship today — sync.*, net.tls, core.reflect, every web.* and ui.* module — and importing one gives an error like the one above.

Projects

When a script grows into several files with tests, make it a project:

loup-bootstrap init notes
cd notes

(On macOS, Windows and Linux arm64, loup init is the same command; Chapter 1 explains the two binaries.) This creates:

notes/
├── notes.lppkg     # the manifest
├── src/
│   └── main.loup   # the entry point
└── tests/

A project's entry point is a host.dual program with a main(), and its modules are imported by their path under src/. Add src/text/slug.loup:

@mode(host.dual)

## Lower-case `text` and join its words with hyphens.
pub func slugify(text: string): string {
    let words = text.trim().toLowerCase().split(" ");
    let kept = words.filter(func(w: string) => w.length() > 0);
    return kept.join("-");
}

Listing 11-9: src/text/slug.loup

and use it from src/main.loup:

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

from text.slug import (slugify);

func main(): int {
    for (title in runtime.args()) {
        println(slugify(title));
    }
    return 0;
}

Listing 11-10: src/main.loup imports text.slug from src/

loup run src/main.loup "Hello World" "  Error  Handling "
hello-world
error-handling

loup-bootstrap check, run anywhere inside the project, type-checks every module without running anything and prints nothing when all is well. Library modules in a project are usually host.dual too, so the whole project can later be compiled ahead of time; a host.dual module may contain only declarations and constant initializers at the top level.

The Manifest

The manifest is a TOML file named <project>.lppkg (or package.lppkg). loup-bootstrap init writes this, plus a number of empty tables:

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

[build]
entry = "src/main.loup"
delivery = "cli"
profile = "release"

[test]
pattern = "**/*_test.loup"

[dependencies]

[dev_dependencies]
Key Meaning
name, version the package's name (lowercase, hyphens allowed, dots for namespaced packages) and its semantic version
loup the minimum toolchain version
[build] entry the entry module
[build] delivery what loup-bootstrap build produces: cli (a WebAssembly module run by loup-launcher), service, browser, desktop, native (older manifests spell it target)
[build] mode the default @mode for files that declare none
[test] where loup test looks for tests (Chapter 16)
[dependencies], [dev_dependencies] packages the project needs

The Language Reference's examples write package my-app and [deps] without TOML quoting; a manifest must be valid TOML, so use the form above.

Sharing Code Between Projects

⚠ Planned. The package registry and the commands that use it — loup add, loup publish, a working loup install, and loup.lock — are described in the Packages Reference but not available in Loup 1.25. loup add and loup publish are unknown commands, and loup install stops as soon as the manifest lists a dependency:

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 works today is LOUPPATH: a list of directories (separated by :, or ; on Windows) that loup run searches for packages. Each entry is a root that contains packages; a package lives in a directory under it with its own manifest. With a word-tools package in ~/dev/packages/word_tools/ (a hyphen in a package name becomes _ in its directory):

~/dev/packages/
└── word_tools/
    ├── word-tools.lppkg   # name = "word-tools"
    └── count.loup         # pub func wordCount(…)

any script can import it:

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

from word_tools.count import (wordCount);

func main(): void {
    println(wordCount("one two three"));
}
LOUPPATH=~/dev/packages loup run src/main.loup
3

Without LOUPPATH, the import fails with unresolved package import 'word_tools/count', listing the roots it searched. In 1.25, loup-bootstrap check does not read LOUPPATH, so it reports such an import as E-CHECK-DEP … not found; use loup run to check a program that depends on one.

Summary

  • Every source file is a module, imported by its path relative to the importing script (or to src/ in a project).
  • pub exports a declaration; everything else is private to the file.
  • from m import (a, b as c); always has parentheses; import m as name; binds a whole module; from p { m import (…); } groups imports. There are no wildcard imports and no pub use or export.
  • pub from .m import (…); re-exports; an index file makes a directory importable.
  • In 1.25, importing a module runs its declarations and let initializers but not its other top-level statements.
  • Circular imports are E_CIRCULAR_IMPORT; defer import breaks a cycle between types.
  • JSON is system.json; the reserved namespaces are the standard library's.
  • loup-bootstrap init creates a project with a TOML .lppkg manifest and a host.dual entry point. The registry (add, publish, install) is planned; share local packages with LOUPPATH.

Try It Yourself

  1. Split Listing 10-9 (the inventory parser) into inventory/parse.lpscr with pub func parseInventory and a report.lpscr that imports it.
  2. Add inventory/index.lpscr that re-exports parseInventory and Item, and change report.lpscr to import from inventory.
  3. Make two modules import each other and read the diagnostic. Then fix it by moving the shared struct into a third module.
  4. Create a project with loup-bootstrap init, move the inventory code under src/, and run loup-bootstrap check.