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:
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:
fn base_price "dango"
350
fn base_price "taiyaki"
500
fn base_price item
err "we do not serve {item}"
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:
import "./teahouse"
dango = price "dango"
pocky = price "pocky" .? (e -> "gomen: {e.reason}")
dango .> describe .> print .> (_ -> print pocky)
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.
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
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.
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:
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")
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:
fn double n
n * 2
test_double = double 4 == 9
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:
import "std/expect"
fn double n
n * 2
test_double = expect (double 4) . to (equal 9)
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.
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:
import "./teahouse"
print "dango costs {teahouse/price "dango"}"
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:
import "./teahouse"
print "the wholesale number is {teahouse/base_price}"
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.
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
import "../geo"
print "one wheel: {area 3.0}"
print "the pair: {geo/area 2.0}"
print "a ride: {ride_km 1.0 2.0}"
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.
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:
import "../geo"
print "{geo/square 3.0}"
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:
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}"
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:
import "../geo"
pub area
pub fn lap r
area r + area r
import "../resort"
print "a lap: {lap 1.0}"
print "area came through: {area 1.0}"
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:
import "../resort"
print "{resort/ride_km 1.0 2.0}"
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.
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:
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:
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
pub fn index page
"all posts, page {page}"
pub fn show id:int
"post {id}"
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/fnruns 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
- split the teahouse further. add a third module file
order.ksoholding apub fn totalthat takes a list of item names, prices each throughprice, and sums them. it calls intoservice.ksoacross the file boundary with no import. add atest_constant for a two-item order and runkanso test teahouse. - trigger the canonical-order error on purpose in two ways: two imports out of alphabetical order, and a
typeplaced below afn. read eacherror[formatting]and predict the fix before you make it. - write a test that must fail, once with
==and once withexpect, and compare the two reports. then make both pass by fixing the assertion and watch the count change. - from a file outside the teahouse module, import it and try to read
base_price. read theerror[opacity]you get back. then markbase_priceaspub, 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. - make two small modules that both export a function named
areawith the same shape, import both from a third, and callareabare. read the refusal, then qualify both calls and confirm each spelling reaches its own module. - in the resort module from this chapter, re-export
areaunder a new name withpub area:footprint. confirm the visitor can callfootprintand that bareareano longer arrives. - call a private helper of an imported module by its qualified name. read the
error[opacity]diagnostic, then make the helperpuband watch the same call succeed. - geo's
ride_kmtakes two arguments. declare a localride_kmwith 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 confirmgeo/ride_kmstill reaches the other.