Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Module System

A module is a .zu file. It runs at most once per program, it gets its own global namespace, and nothing it declares is visible anywhere else until someone imports it.

That is the whole model. The rest of this chapter is the syntax and the resolution rules.

Importing

import math
import os

echo math.PI
echo os.cwd()

import name binds the module under that name. Reach into it with a dot.

Picking Out Members

import math { PI, E }

echo PI

The named members are bound directly, and the module name itself is not.

{ * } brings in everything public:

import math { * }

echo PI
echo ROOT_2

Renaming

import http.websocket as ws

Use this when the natural name is long, or when it would collide.

as renames the module, not a member. There is no way to rename an individual name in a member list — import math { PI as pi } does not parse. When you want a different name for one imported thing, bind it yourself:

import math { PI }

var pi = PI

echo pi
3.141592653589793

Relative Imports

A path starting with . or .. is resolved against the directory of the file doing the importing, and is never searched for anywhere else.

import .helpers             # next to this file
import .models.user         # models/ next to this file, then user
import ..shared.config      # up one directory, then shared/, then config
import .. ..shared.config   # up two directories, then shared/, then config

Each .. climbs one directory, so .. .. climbs two and .. .. .. three. The dots may also be written together, ....shared.config, and it is the same import: a run of dots counts two for each directory up. zuri fmt leaves whichever way the path is written as it is.

Each segment resolves the same way a bare name does: name.zu is tried first, then name/index.zu. So import .helpers finds either helpers.zu or helpers/index.zu, and import .models.user finds models/user.zu or models/user/index.zu, with models itself being a directory either way.

Which one it finds is invisible at the import site, and that is what lets a module grow into a package: split helpers.zu into helpers/index.zu plus some siblings, and every import .helpers keeps working untouched.

Inside a package, a sibling is always import .sibling, never the full path from the project root. Writing import myapp.models.user from inside myapp/models/ sends the resolver out to the library search path and it will not find anything.

Exporting

Imports are local by default. If a.zu imports b.zu, a third file that imports a does not see b’s contents through it.

Prefix the path with @ to re-export:

import @.util { * }

Now everything util.zu exposed is part of this module’s public surface too. All three forms take the prefix:

import @.module            # the module itself is re-exported
import @.module { item }   # just that item
import @.module { * }      # everything

This is how a package’s index.zu assembles a public API out of several private files:

Filename: pkg/index.zu

import @.util { * }
import .sub.deep { deep_slug }

def hello() {
  return 'hello from pkg ' + VERSION
}

util’s members are re-exported. deep_slug is imported for this file’s own use and stays private.

Privacy

A member whose name starts with _ is private to its module and cannot be imported by name:

import .pkg.util { _secret }
SyntaxError: Cannot import private items from module
  --> /path/to/bad.zu:1:20
  |
1 | import .pkg.util { _secret }
  |                    ^

{ * } skips private members too. Prefix anything that is an implementation detail and the module system will keep it that way.

Packages

A directory with an index.zu is a package, and importing the directory runs its index.zu:

pkg/
  index.zu
  util.zu
  sub/
    deep.zu
import .pkg            # runs pkg/index.zu
import .pkg.util       # runs pkg/util.zu
import .pkg.sub.deep   # runs pkg/sub/deep.zu

The same rule applies to zuri run pkg on the command line, which is what makes a package runnable as well as importable.

How a Bare Name Is Resolved

import http, with no leading dot, is searched for in this order:

  1. .zuri/libs/http in the project
  2. $ZURI_ROOT/libs/http, or libs/http beside the zuri executable: the standard library
  3. a built-in native module named http
  4. $ZURI_HOME/libs/http, the packages installed for your user, which is ~/.zuri/libs unless ZURI_HOME moves it

At each step, http.zu is tried first and then http/index.zu.

The project is the nearest directory holding a project.toml, looking upwards from the script zuri run was given, or from the working directory for a command or the REPL. A program run from anywhere inside a project imports that project’s packages, and a program in no project uses .zuri/libs in the working directory. The project is found once, when the program starts, so changing directory part way through does not change where imports come from.

zuri install fills .zuri/libs, and Packages and Nyssa covers it in full. Step one is also what makes vendoring work. Dropping a file into .zuri/libs/ shadows a standard library module of the same name:

$ cat .zuri/libs/mylib.zu
def hi() { return 'from user libs' }

$ cat uses.zu
import mylib
echo mylib.hi()

$ zuri run uses.zu
from user libs

Modules Run Once

A module’s top level executes the first time it is imported, and never again. Every later import of the same file gets the same module object:

import .once
import .once as again
import .once
side effect ran

One line of output, three imports. Identity is by canonical filesystem path, so two different relative paths to the same file are the same module.

This makes a module’s top level the natural place for setup that must happen exactly once: opening a connection pool, reading a config file, registering handlers.

Circular Imports

A circular import works. A module is registered before its body runs, so when b.zu imports a.zu while a.zu is still loading, it gets the partially built module rather than looping forever:

Filename: a.zu

import .b

def from_a() {
  return 'a'
}

echo 'a loaded'

Filename: b.zu

import .a

echo 'b loaded'
b loaded
a loaded
a

The catch is visible in that output: b finished loading before a did, so anything b reads from a at its top level is not there yet. Reading it from inside a function is fine, because by then a has finished.

If a module fails while loading, it is dropped from the cache rather than left behind half built, so a later import genuinely retries.

Module Variables

Every module gets two names for free:

echo __file__
echo __root__

__file__ is this module’s own canonical path. __root__ is the entry file the program was started from, and it is the same in every module. Use them to locate files relative to your source rather than relative to whatever directory the user happened to run from:

import os

var templates = os.join_paths(os.dir_name(__file__), 'templates')

In the REPL both are defined, with placeholder values standing in for the file that does not exist:

%> __file__
@.repl
%> __root__
@.repl.root

They differ from each other there, so the __root__ == __file__ check below is false at the prompt — a REPL session is never the entry point of a program.

Running as a Program, Importing as a Module

Because __root__ is the entry file and __file__ is this one, comparing them tells a module whether it is the program being run or something being imported:

Filename: tool.zu

def add(a, b) {
  return a + b
}

if __root__ == __file__ {
  echo 'running as a program: ' + add(2, 3)
}
$ zuri run tool.zu
running as a program: 5
import .tool
echo tool.add(10, 20)
30

The same file is a clean, side-effect-free library when imported and a runnable command-line program when launched directly. Put the argument parsing and the entry point behind that check, and everything else above it.

These two names describe which file this is, so they are not exports. A wildcard import copies every public name out of the module it names, and __file__ and __root__ are deliberately excluded from that:

import math { * }

echo __file__ == __root__
true

That guarantee is what makes the __root__ == __file__ check above reliable in every file, including one that wildcard-imports a sibling.

Structuring a Project

A small program is one file. Past that, the shape that works is a package per area of responsibility, each with an index.zu that re-exports what is public:

myapp/
  index.zu          # import @.routes, import @.models, then start
  config.zu
  models/
    index.zu        # import @.user { * }, import @.task { * }
    user.zu
    task.zu
  routes/
    index.zu
    api.zu
    pages.zu
  storage/
    index.zu
    _json_store.zu  # private: the leading underscore says so

Run it with zuri run myapp. The capstone in Chapter 25 is laid out exactly this way.

A Package, End to End

Here is the smallest complete version of that shape. Three files, one package, one public entry point.

Filename: greet/english.zu

def hello(name) {
  return 'Hello, ${name}'
}

def _shout(text) {
  return text.upper()
}

Filename: greet/french.zu

def hello(name) {
  return 'Bonjour, ${name}'
}

Filename: greet/index.zu

import @.english
import @.french

def greet(name, language) {
  return language == 'fr' ? french.hello(name) : english.hello(name)
}

Filename: index.zu

import .greet

echo greet.greet('Ada', 'en')
echo greet.greet('Ada', 'fr')
echo greet.english.hello('Grace')
$ zuri run
Hello, Ada
Bonjour, Ada
Hello, Grace

Four things to take from it.

greet/index.zu is what import .greet loads. A directory with an index.zu is a package, and importing the directory runs that file.

The @ on import @.english is what re-exports it. Without it, greet.english would not be reachable from outside greet/index.zu, even though greet() itself would still work — which is often exactly what you want.

Both submodules define hello, and they do not collide. Each lives in its own namespace, reached through its own module name. That is the whole reason to use import @.english rather than import @.english { * } here.

_shout is unreachable from outside. Writing greet.english._shout(x) anywhere else is a compile error, not a runtime one:

SyntaxError: '_shout' is private and can only be accessed via 'self' or 'parent'

The leading underscore is the only declaration of privacy there is, and it is checked before the program runs.