chapter 07

modules and tests

a kanso module is a directory of library files. the files share one namespace: a name declared in one is in scope in all the others, with no import or export between them. a program is an entry file that imports the modules it uses. here is a small teahouse laid out that way:

the teahouse on disk
teahouse/
  main.kso                 the entry: statements that run
  teahouse/
    menu.kso               the module: two files, one namespace
    service.kso
    service_test.kso       tests, read only by kanso test

the module holds the prices. menu.kso knows what each item costs, and service.kso adds the service charge and says how to describe a total:

teahouse/teahouse/menu.kso
fn base_price "dango"
  350

fn base_price "taiyaki"
  500

fn base_price item
  err "we do not serve {item}"
teahouse/teahouse/service.kso
pub fn describe n
  "{n} yen, service included"

pub fn price item
  base_price item + 50

price calls base_price, which lives in the other file, and neither file imports anything. the entry is the only file that names the module:

teahouse/main.kso
import "./teahouse"

dango = price "dango"
pocky = price "pocky" .? (e -> "gomen: {e.reason}")
dango .> describe .> print .> (_ -> print pocky)
kanso run teahouse
400 yen, service included
gomen: we do not serve pocky

point the toolchain at the directory and it finds the entry. import "./teahouse" brings in the module's pub names, and main.kso calls them bare, as price and describe. three earlier chapters meet in these files. string-literal dispatch is the menu. an err is the missing item, and it passes the + 50 in price without running it (chapter 04). the entry rescues it with .?, which it may do because the err was born in another module, and the callback reads the failure's reason. describe has no arm for an err, so the dango total reaches it through .>, which hands on a value and steps past a failure.

an entry file holds statements and nothing else. the definitions live in the module, and the compiler refuses a fn or a type in an entry. that is why the rescue's callback is a lambda: a named function would have to be declared in a library file. a single file that holds both, as most of the earlier chapters' samples do, is a play file, run with kanso play; it is the scratch-pad form, and it may import only the standard library.

one namespace, many files

in most languages a file is a unit of encapsulation: each file is its own scope, and moving a name from one file to another costs an import at one end and an export at the other. in kanso the module is the unit, and the files are where you chose to put things. every top-level name in menu.kso is visible in service.kso, and the reverse holds too.

the reason is that a file boundary with meaning is a second boundary to keep consistent with the first. two files that must agree on what crosses between them drift the moment someone forgets one side. file names still carry information—menu.kso holds the menu—and a name is defined in exactly one place in the directory, so finding it is a search. imports work the other way: each file imports what it calls, so a file that calls list/fold imports std/list even when the file beside it already does.

canonical order within a file

inside a file, two things about order are fixed. imports come first, in alphabetical order, and types come before functions. get either wrong and the file does not compile. everything else is in the order you write it.

shop.kso
pub type receipt
  total

pub fn checkout item qty
  receipt (line item qty)

fn line item qty
  qty * price item + service

pub play = print "{checkout "matcha" 2}"

fn price "matcha"
  600

fn price "mochi"
  300

service = 50
kanso run shop.kso
shop/receipt 1250

read the declaration heads top to bottom: the type receipt, then checkout, line, play, price, service. that is the order the author chose, and the compiler has nothing to say about it. definition order is not scope either: checkout calls line and line reads service, both defined below them, and it all resolves.

so put a helper beside the thing it helps, and keep the entry point wherever a reader will look for it. the two price arms sit together here because reading them together is the point. nothing requires it: an overload set is one declaration split across arms, and the arms may be written wherever they read best.

there is no formatter to run and no ordering debate to have in review. the two rules that are enforced, imports first and alphabetical and types before functions, are errors the compiler names and points at, the same as a stray brace.

enso-neko - sleeps in a circle she never quite closes
ensō-neko

imports at the top, types next, and then the order that reads best. i keep each helper under the function that calls it, so a file reads downward like a recipe. split files by topic, the way a real library does, and the file names become the module's table of contents.

private by default

look again at the two module files: base_price is bare, and price and describe wear pub. that keyword is the whole visibility system. a top-level name is private to its module unless it is marked, so base_price is the teahouse's own business and the entry cannot reach it. there is no export list to maintain. pub sits on the declaration, so a reader sees the surface where each thing is defined: in shop.kso above, receipt, checkout and play are the api, and line, price and service are interior.

pub goes on every kind of declaration: pub fn, pub type, and pub on a constant. an importer sees only pub names, and reaching for anything else is error[opacity], which names the module whose interior you touched. the last section of this chapter shows one.

tests are constants

chapter 01 showed that a test is a constant whose name starts with test_. a test file sits in the module's directory and its name ends in _test.kso. kanso test compiles it as an importer of the module beside it, so it sees what any caller sees: price and describe, and not base_price. helpers the tests need stay in the test file, and kanso run, check and build never read it:

teahouse/teahouse/service_test.kso
import "std/testing"

test_dango_price = price "dango" == 400

test_describe = describe 400 == "400 yen, service included"

test_unknown_item_fails = testing/failed? (price "pocky")
kanso test teahouse
test_dango_price ... ok
test_describe ... ok
test_unknown_item_fails ... ok
3 passed, 0 failed

a test is a boolean constant. the runner evaluates each test_ name and reports what came back. test_dango_price is the comparison price "dango" == 400, and nothing else is needed to write it. test_unknown_item_fails needs one helper from std/testing, because a failure is a box and == reads only values (chapter 04). testing/failed? rescues the box and answers whether there was a failure in it, which it may do because the failure comes from the module under test.

when a plain comparison fails, the report can only say that it came out false. by the time the runner has the boolean, the two values that were compared are gone:

double_test.kso
fn double n
  n * 2

test_double = double 4 == 9
kanso test double_test.kso
test_double ... FAILED (returned false)
0 passed, 1 failed

std/expect keeps them. expect hands its value on unchanged, and to (equal 9) answers true when the value matches and a mismatch record holding both sides when it does not. the runner prints whatever a failing test returned, so the record is the diagnostic:

double_expect_test.kso
import "std/expect"

fn double n
  n * 2

test_double = expect (double 4) . to (equal 9)
kanso test double_expect_test.kso
test_double ... FAILED (returned expect/mismatch 9 8)
0 passed, 1 failed

read the record as wanted, got: the test wanted 9 and double 4 gave 8. use == for a quick check and expect where a failure needs to say what it saw. the same constants pin a teahouse's prices here and, in chapter 13, a json parser's escapes, unicode handling and error positions.

mugi the tanuki - runs the interpreter; pays for everything in boba pearls
mugi

a test is a value, so everything you know about values applies: no setup, no teardown, no ordering, no shared state to leak between examples. a suite of constants cannot flake—there is nothing in it that time can touch. i have checked.

importing another module

one directory is one module; a program grows by putting modules side by side. an import names a sibling directory, and every pub name inside arrives under the module's own name as a qualifier—teahouse/price, spelled where it is used, so a reader never wonders where a name came from:

shop/main.kso
import "./teahouse"

print "dango costs {teahouse/price "dango"}"
kanso run main.kso
dango costs 370

the boundary works in both directions. inside the teahouse, base_price is an ordinary name any file of the module may read; from outside, it does not exist, and asking for it is a compile error rather than a discouraged convention:

shady/peek_check.kso
import "./teahouse"

print "the wholesale number is {teahouse/base_price}"
kanso check peek_check.kso
error[opacity]: `base_price` is private to module `teahouse` — only pub names cross an import
  --> peek_check.kso:3:33
   3 | print "the wholesale number is {teahouse/base_price}"
                                       ^

that is one import of one module. the rest of this chapter is what happens between modules: what an import brings in, which names cross, and how two modules share a name without a conflict.

an import enrolls names

here is a small geometry module and a program that uses it. geo/geo.kso marks two functions pub; a third is private working machinery.

geo/geo.kso
import "std/math"

pub fn area r
  3.14159 * square r

pub fn ride_km a b
  math/sqrt (area a + area b)

fn square x
  x * x
trip/main.kso
import "../geo"

print "one wheel: {area 3.0}"
print "the pair: {geo/area 2.0}"
print "a ride: {ride_km 1.0 2.0}"
kanso run trip
one wheel: 28.27431
the pair: 12.56636
a ride: 3.963325623765981

one line declares the dependency, and every pub name arrives twice over. the bare spelling—area, ride_km—enrolls into the file's overload space, where it dispatches like any local function. the qualified spelling—geo/area—is the name's permanent identity, always valid, and it doubles as a tool: writing the qualifier narrows dispatch to that module's arms alone.

there is no list of imported names to maintain. the compiler prunes whatever the file never uses, and your editor's language server answers "where did this come from?"—provenance is tooling's job.

enso-neko - sleeps in a circle she never quite closes
ensō-neko

in a file that imports more than one module, i reach for the qualified spelling—geo/area answers where a name came from without leaving the line. save the bare spelling for the vocabulary your file is actually about.

shared names are the normal case

two modules will export the same word, and a local function may use it too. all of them land in one overload space, and dispatch picks by specificity—the same mechanism that chooses between a fact 0 arm and a fact n arm chooses between a local select on invoices and the list module's select. a local arm sorts before an imported one when the shapes tie, so your own definitions win the close calls. when two imports collide head-on—the same name, the same shape, and no local arm to break the tie—a bare call is refused, and the fix is one qualifier.

a value brings its arms

a module can be handed a value whose type it has never imported. geometry writes area for its own circle, and a report module that takes a circle as a parameter may call area c without importing geo: the circle arrived as an argument, and it carries the arms its owner wrote. the same holds for a field of an argument, an element of a list passed in, and whatever such an arm answers.

a value the file made itself carries nothing extra. area 3 in the report module is an unknown name, because nothing the module declares or imports is called area. an arm travels only from the module that owns the type, so a module that does not own circle cannot add an arm that goes wherever circles go. checked on its own, the report module accepts area c on a parameter. the program that hands it a circle is where the compiler finds geo's arm, or refuses the call when no owner wrote one.

a module may write the same name for a type of its own. if the report module declares a square and an area for it, a square it is handed takes that arm and a circle it is handed still takes geo's. a local arm hides only an owner's arm of the same shape, one that matches the same types in the same places. when two owners write arms that are equally specific for one call, as when one matches its own type first and the other matches its own type second, the compiler refuses the pair and asks for an arm that is the more specific everywhere.

privacy holds at the boundary

inside a module, files see everything; pub only matters to outsiders. reach for private machinery across an import and the boundary answers:

nosy/main.kso
import "../geo"

print "{geo/square 3.0}"
kanso run nosy
error[opacity]: `square` is private to module `geo` — only pub names cross an import
  --> nosy/main.kso:3:9
   3 | print "{geo/square 3.0}"
               ^

renaming on the way in

sometimes an imported name deserves a different one in your file—it collides with a word you want, or your domain has a better word. braces rename, and an identifier before them aliases the qualifier itself. the two combine:

picks/main.kso
import g { area:disc } "../geo"

print "renamed: {disc 2.0} and {g/disc 2.0}"
print "the alias still reaches: {g/ride_km 1.0 2.0}"
kanso run picks
renamed: 12.56636 and 12.56636
the alias still reaches: 3.963325623765981

a rename replaces the spelling. after area:disc, this file says disc or g/disc; bare area no longer resolves here. the whole module still enrolls—the braces adjust one token, they never select a subset.

re-exports shape a surface

a module's surface is its own pub names. what it imports stays off that surface: depend on a module and you get its exports, never its suppliers'. when a module genuinely wants to pass a name through—the resort below is built on geometry and its guests will need area—it says so with a bare pub line:

resort/resort.kso
import "../geo"

pub area

pub fn lap r
  area r + area r
visitor/main.kso
import "../resort"

print "a lap: {lap 1.0}"
print "area came through: {area 1.0}"
kanso run visitor
a lap: 6.28318
area came through: 3.14159

pub area puts one imported export on the resort's surface. pub geo would re-export the whole module by its qualifier, and pub area:footprint renames on the way out. anything not re-exported stays sealed:

sealed/main.kso
import "../resort"

print "{resort/ride_km 1.0 2.0}"
kanso run sealed
error[name]: unknown name `resort/ride_km`
  --> sealed/main.kso:3:9
   3 | print "{resort/ride_km 1.0 2.0}"
               ^

the visitor imported the resort, and ride_km belongs to geometry—the resort never re-exported it, so it never arrived.

enso-neko - sleeps in a circle she never quite closes
ensō-neko

a surface is easiest to keep small from the start. export the two names your first caller needs and let the next caller ask for the third—adding a pub line is a one-word change, and taking one back breaks somebody.

a convention runs while the program compiles

a web framework often places code by its name: a function called index in a module called posts answers a request for /posts/index. kanso lets a library state a convention like that as an ordinary function, which runs while your program compiles. the entry asks for it with a derive line after its imports:

routed/main.kso
import "./conventions"
import "./posts"

derive conventions/routing

print (conventions/handle routes "/posts/show" 7)
print (conventions/handle routes "/posts/index" 2)
print (conventions/handle routes "/nowhere" 2)

the compiler describes the entry and the modules it imports by a ./ path: each pub function with its parameters, each pub constant with its value, each record type with its fields, and every call a module's functions make to a named function. the entry comes first, under its file's name, and since it holds only statements it carries only its calls. the description also carries the text of each file beside the entry that is not kanso source, by name, so a convention can read a schema.sql the app checks in. it calls conventions/routing with that description on the interpreter. the function answers a list of declarations, and derive/declare adds a constant to the entry. here the constant is routes, a map whose values are the functions themselves:

routed/conventions.kso
import "std/derive"
import "std/list"

# Every pub function of every module, keyed by its path.
pub fn routing p
  rows = list/fold p.modules {} (acc m -> list/fold m.functions acc (row m))
  [(derive/declare "routes" "routes" rows)]

fn row m
  (acc f -> put acc "/{m.name}/{f.name}" f.reference)

pub fn handle table path arg
  called table[path] path arg

fn called none path _
  "no route for {path}"

fn called f _ arg
  f arg
routed/posts.kso
pub fn index page
  "all posts, page {page}"

pub fn show id:int
  "post {id}"
kanso run routed
post 7
all posts, page 2
no route for /nowhere

the checker reads routes as it reads code you wrote, so a route that names a function nobody declared fails to compile. three more declarations exist. derive/declare_type adds a record type to the entry. derive/define adds an arm or a record type, written out as source, to one of the app's modules: an arm fn path_to p:post belongs in the module that declares post, and that module checks it like code someone wrote there. a module's files share their declarations but not their imports, so a definition that calls another module opens with the import lines it needs, and the module's derived file opens with all of them. a relative path in one is written from the derivation's own module, as that module's own imports are, so a convention reaches its library from wherever the app keeps it. a type defined this way is one the module's own code may use, so a module named for a table can annotate, build and read a record whose fields come from the table and appear nowhere in its source. derive/refuse makes a compile error at the function or type it names, and derive/refuse_call makes one at a call the description listed, which is how a convention reports something that does not fit it. kanso derived routed prints every constant, type and arm a build derived.

a module's directory may hold files that are not kanso source, and the description carries each one's text under the module, by name. this is how a template language is built on kanso. a convention reads each template and answers its compiled source with derive/define, and the module compiles that source as one more of its own files. a directory that holds templates and no kanso source at all is still a module: its functions are the ones a derivation writes. senro's kata works this way. views/signed.kata becomes the function views/signed, and nothing is generated on disk beside it. a convention can say where each part of what it wrote came from with a comment of the form # from signed.kata:4, naming the file within the module and its line. a compile error in the derived source then quotes the derived line and adds = written from views/signed.kata:4, the nearest such comment above it in the same definition.

a derivation only computes. it cannot read a file or the clock, and it learns about the app only from the description: a convention that imports one of the modules it describes is refused. the same source therefore derives the same declarations every time, on every engine, including the one in the playground.

what you can do now

you can lay a program out as an entry file and a module directory, and split the module across files that share one namespace. you know the two ordering rules the compiler enforces, imports first and alphabetical and types before functions, and that the rest of a file is in the order its author chose. you can mark a module's surface with pub and leave everything else private. and you can test that surface with test_ constants in a _test.kso file, using == for a plain check and std/expect when a failure should report what it saw. the next chapter turns to sequences, and chapter 13 reads a real library built from these pieces.

between modules, the rules are these:

  • a module is a directory; its files share one namespace.
  • import "path" declares the dependency and enrolls every pub name, bare and qualified. the compiler prunes what you don't use.
  • overloading is the resolution mechanism. specificity picks; a local arm outranks an import on a tie; a head-on collision between imports is an error with a one-word fix.
  • a value carries the arms its type's owner wrote: code handed one calls them bare without importing the owner. a value the file made reaches only names its module declares or imports.
  • import g { theirs:yours } "path" aliases the qualifier and renames a token. a rename replaces the bare spelling.
  • a surface is a module's own pubs plus its explicit re-exports—pub name, pub qualifier, pub theirs:yours. nothing leaks through on its own.
  • derive module/fn runs a convention while the entry compiles. it is handed a description of the app's own modules and answers constants, record types and refusals, which are checked like code you wrote.

exercises

  1. split the teahouse further. add a third module file order.kso holding a pub fn total that takes a list of item names, prices each through price, and sums them. it calls into service.kso across the file boundary with no import. add a test_ constant for a two-item order and run kanso test teahouse.
  2. trigger the canonical-order error on purpose in two ways: two imports out of alphabetical order, and a type placed below a fn. read each error[formatting] and predict the fix before you make it.
  3. write a test that must fail, once with == and once with expect, and compare the two reports. then make both pass by fixing the assertion and watch the count change.
  4. from a file outside the teahouse module, import it and try to read base_price. read the error[opacity] you get back. then mark base_price as pub, watch the error disappear, and decide whether you meant it. widening a surface is a one-word edit; narrowing it later breaks whoever came to rely on it.
  5. make two small modules that both export a function named area with the same shape, import both from a third, and call area bare. read the refusal, then qualify both calls and confirm each spelling reaches its own module.
  6. in the resort module from this chapter, re-export area under a new name with pub area:footprint. confirm the visitor can call footprint and that bare area no longer arrives.
  7. call a private helper of an imported module by its qualified name. read the error[opacity] diagnostic, then make the helper pub and watch the same call succeed.
  8. geo's ride_km takes two arguments. declare a local ride_km with a different arity (in a library file beside your entry), import geo, and confirm both dispatch by argument count with no qualifier. then match the arity and work out which one answers a bare call—your own declaration outranks the import—and confirm geo/ride_km still reaches the other.