chapter 05

effects are io

every kanso function is pure, so evaluating print "hi" performs no i/o. it answers an io, a value like any other, that describes the printing. a whole program evaluates to one such value describing everything it means to do, and the runtime receives that value and performs it. evaluating decides what will happen; the runtime makes it happen.

you can see the seam directly, because an io is data, and data can be rendered instead of executed. a bind whose callback ignores its argument, .> (_ -> next), glues io values into bigger ones, "this, then that", and the --plan flag from chapter 01 shows the glued-up value without running it:

brew.kso
print "brewing" .> (_ -> print "steeping") .> (_ -> print "pouring")
kanso run brew.kso --plan
plan:
  print "brewing"    # from line 1
  print "steeping"    # from line 1
  print "pouring"    # from line 1
kanso run brew.kso
brewing
steeping
pouring

--plan needs no machinery of its own. the plan already exists as a value, and the flag prints it instead of handing it to the executor. each step carries the source span it came from, which is where the # from line comments come from. the rest of this chapter follows from the same fact: an effect is a value.

an io is a value

take that fact one consequence at a time. first, a program whose value is a plain number is legal. it computes the number, tells no one, and exits cleanly, because there is no io to perform:

quiet.kso
6 * 7
kanso run quiet.kso --plan
error: main is not an io; there is no plan to show

kanso run quiet.kso prints nothing and exits zero. the answer 42 was computed and discarded, because a print is itself a value, and this program never built one. ask for the plan and the toolchain says so in as many words.

second: a value can be bound to a name, and a name can be used more than once. an io doesn't remember having run, because it hasn't—splice it in three times and the program means three prints:

knock.kso
knock = print "kon kon"

knock .> (_ -> knock) .> (_ -> knock)
kanso run knock.kso --plan
plan:
  print "kon kon"    # from line 1
  print "kon kon"    # from line 1
  print "kon kon"    # from line 1
kanso run knock.kso
kon kon
kon kon
kon kon

in a language where print fires on evaluation, knock would print once, at definition, and the reuse would be a mystery. here the constant is an io, the three uses are three copies in the final value, and the plan shows all three—each pointing back at the one line that built it.

third: interpolate an io into a string and you get its face, not its result. there is no result yet; nothing has run:

render.kso
step = print "steep"

print "an io renders as {step}"
kanso run render.kso
an io renders as <io>

and fourth: because intent is a value, dropping intent on the floor is visible—and refused. bind an io and never use it, and the program has written down a thing it will never do. kanso treats that as a bug at compile time, as it does any binding nobody reads:

dropped_check.kso
goodbye = print "sayonara"
print "the goodbye never ran"
kanso check dropped_check.kso
error[unused]: unused binding `goodbye`
  --> dropped_check.kso:1:1
   1 | goodbye = print "sayonara"
       ^

those four programs show that nothing about print is special. it obeys binding, reuse, interpolation and the unused-binding check like every other value in chapter 02, because it is one.

pipes, and pipes into effects

the other operator you've been glimpsing is .—the dot. x . f is f x, read left to right, so data flows the way your eye does:

pipes.kso
fn greet name
  "hello, {name}"

"kanso" . greet . print
output
hello, kanso

the dot does not stop at one argument. whatever follows the function is written after the piped value, which arrives first — so a function meant to be piped into takes its subject first and its settings after:

pipe_args.kso
fn clamp n lo hi
  return lo if n < lo
  return hi if hi < n
  n

print "{12 . clamp 0 10}" .> (_ -> print "{4 . clamp 0 10}")
output
10
4

12 . clamp 0 10 is clamp 12 0 10. read the arguments after the name as the ones you were always going to write, with the subject lifted out in front of them.

now pipe out of an io. read_file path doesn't return a string—it returns an io describing the read, and the file's contents don't exist until the runtime performs it. so an io is piped with .>, the bind dot, and kanso wires the function to receive the value once it exists. piping into an io is bind, and the spelling says so:

flavors.txt
matcha
ube
hojicha
count.kso
import "std/list"
import "std/os"
import "std/text"

fn count_lines text
  newlines = list/count (text/chars text) (c -> c == "\n")
  "flavors.txt has {newlines} lines"

pub play = os/read_file! "flavors.txt" .> count_lines .> print
kanso run count.kso
flavors.txt has 3 lines
kanso run count.kso --plan
plan:
  read_file "flavors.txt"
  . <continuation>
  . <continuation>
  . <continuation>

look at that plan. the runtime can name the first step, but the rest is <continuation>—honest notation for "what happens next depends on a value i don't have yet." compare the plan for brew.kso, which listed every step. each of its callbacks was _ -> next, which ignores what it is handed, so the plan could call it without a value. count_lines reads the file's contents, so its step cannot be shown until the read has happened. .> is the only way to say "before" in kanso. everywhere else the runtime chooses the order, which is chapter 06's subject.

note what count_lines is: an ordinary pure function that takes a string. it doesn't know files exist, and nothing in its signature says "i get used near i/o." there's no async keyword to sprinkle through every caller—effectfulness travels in the value itself. a function that returns an io is effectful; a function that returns a string is not; and the same function can be piped from a file today and called on a literal in a test tomorrow.

effects still have types. an effect has the type <t>effect, and the compiler infers it from the io values you use, the way it infers every other type. so the compiler knows which of your expressions is a box, and refuses one wherever the thing inside is what is wanted:

boxed.kso
import "std/os"

print (length os/args)
kanso check boxed.kso
error[effect]: this is an effect — a box the words open — and `length` takes a value; open it with `.>`
  --> boxed.kso:3:15
   3 | print (length os/args)
                     ^

os/args is an io, so length is being handed the box rather than the list inside it, and the compiler says so before the program runs. the same refusal covers +, if, an index, ==, and any function of your own that wants a value. the fix is the operator you already know: os/args .> (a -> print (length a)) opens the box and lets length see the list. a box is opened only by .> and its relatives, never by accident.

a box has a type

an io is a value, and a value can be handed around before anyone opens it. a function that wants the box itself, rather than what comes out of it, says so in its signature: <string>effect is the type of an io that will yield a string. the box arrives unopened, and the function opens it with .> the way count.kso did above:

counted.kso
import "std/list"
import "std/os"
import "std/text"

fn count_lines text
  newlines = list/count (text/chars text) (c -> c == "\n")
  "flavors.txt has {newlines} lines"

fn counted e:<string>effect
  e .> count_lines

counted (os/read_file! "flavors.txt") .> print
kanso play counted.kso
flavors.txt has 3 lines

counted holds the read and hands it on with a step attached. nothing has been read when it returns. its answer is another io, the same box with count_lines wired behind it, and the runtime performs the whole chain when the program's last line reaches the executor. the annotation does one job: it names the parameter as a box, so an arm can dispatch on "an io that yields a string" the way another arm dispatches on n:int.

the other direction is refused. length reads a string; hand it the io and kanso check stops before anything runs:

unopened_check.kso
import "std/os"

pub play = print (length (os/read_file! "flavors.txt"))
kanso check unopened_check.kso
error[effect]: this is an effect — a box the words open — and `length` takes a value; open it with `.>`
  --> unopened_check.kso:3:27
   3 | pub play = print (length (os/read_file! "flavors.txt"))
                                 ^

the message names the fix. a box travels wherever a value travels—into a parameter, a list, an interpolation—but where something reads the value inside, the box has to be opened first, and .> is how. the compiler refuses only what it can prove is a box: an io from the standard library, or a function of your own whose every arm ends in one.

holding an argument back

the dot threads a value into a function that is ready for it. sometimes the function is not ready — it wants two things and you only have one. & supplies what you have and waits for the rest:

currying.kso
fn tax rate price
  price + price * rate / 100

fn quote pricer amount
  "{amount} becomes {pricer amount}"

local = &tax 8
print "{quote local 250}" .> (_ -> print "and again: {local 100}")
kanso play currying.kso
250 becomes 270
and again: 108

&tax 8 fixes the rate and hands back something that still wants a price. it is a value like any other: bind it to a name, pass it to quote, call it twice. no lambda was written and no wrapper function was declared.

the sigil is required. tax 8 on its own is a call, and a call that matches no arm is an error, so a bare application never means "wait for more". with overloading, whether an application is finished cannot be read off the text: if tax also had a one-argument arm, tax 8 would be a complete call, and the partial you meant would be unreachable. & says which one you meant, so the meaning cannot change when somebody adds an arm later.

& supplies arguments and never runs anything, and that stays true when there is nothing left to supply. &tax 8 250 has handed over both arguments and is still a value — one that is waiting to be called. () is what calls it:

running.kso
fn tax rate price
  price + price * rate / 100

on_250 = &tax 8 250
print "still waiting: {on_250}" .> (_ -> print "called: {on_250()}")
kanso play running.kso
still waiting: <fn>
called: 270

& supplies arguments without running anything, and () runs without supplying anything. a value that still wants arguments cannot be run with (), because () brings none and an arm that wants one does not match.

the rest of the i/o vocabulary

writing is an io too. write_file path content describes putting a string at a path, and it composes with everything you've seen—here one bind puts a write before a read of the same file, and a second hands the read's contents onward:

save.kso
import "std/os"

order = "one taiyaki, extra custard"
os/write_file "order.txt" order .> (_ -> os/read_file! "order.txt") .> print
kanso run save.kso --plan
plan:
  write_file "order.txt"
  read_file "order.txt"
  . <continuation>
  . <continuation>
kanso run save.kso
one taiyaki, extra custard

the round trip proves the sequencing: the read found the file because the first bind put the write before it. the two binds do different jobs: the first ignores what the write yields and only orders the two effects, and the second moves the read's result into print.

every effect that succeeds yields a value, including the ones with nothing to report. a write yields done, the fourth nullary alongside true, false and none. the _ in save.kso throws that yield away. give the callback a parameter it reads and you can look at it:

yielded.kso
import "std/os"

fn name done
  "done"

fn name none
  "none"

fn report yield
  print "the write yielded {yield}"
    .> (_ -> print "an arm named {name yield} caught it")
    .> (_ -> print "yield == none is {yield == none}")

os/write_file "receipt.txt" "one taiyaki" .> report
kanso play yielded.kso
the write yielded <done>
an arm named done caught it
yield == none is false

it renders in brackets because it is not data, the same convention that prints <none> and <io>. otherwise it is an ordinary value: an arm can name it, a typeset can hold it, and it equals itself and nothing else. done exists to keep two things apart. none means something is absent—a key that was not in the map, an environment variable nobody set. a finished write is not an absence. while both yielded none, a chain that bound the result could not tell the two apart.

args is the argument list (everything after -- on the command line), and stdin is standard input. both are io values—the argument list and the input stream belong to the outside world, so touching them is an effect like any other, and they pipe like everything else:

welcome.kso
import "std/os"

fn first xs
  xs[1]!

fn greet name
  "irasshaimase, {name}"

os/args .> first .> greet .> print
kanso play welcome.kso -- clay
irasshaimase, clay
shout.kso
import "std/io"

io/stdin .> shout .> print

fn shout text
  "{text}!!"
kanso play shout.kso < pipe.txt
hello from a pipe!!

so the i/o of part i is print, which needs no import, and os/read_file, os/write_file, os/args and io/stdin behind two imports, all combined with the operators you already know. the two modules split the way go splits them: std/os is the machine the program runs on, its files, environment, arguments and processes, and std/io is what it reads and writes through.

failure at the edge

run welcome.kso with no argument and the strict index inside first fails—at execution time, because that's when the argument list exists:

welcome.kso, run bare
import "std/os"

fn first xs
  xs[1]!

fn greet name
  "irasshaimase, {name}"

os/args .> first .> greet .> print
kanso play welcome.kso
error[endpoint]: unhandled err reached the executor: "missing index 1"
  born in first at welcome.kso:4

read the wording against chapter 04. there the message ended "reached the entry," because the entry was where evaluation stopped. here it ends "reached the executor": evaluation finished long ago and handed back a perfectly healthy io, and the failure happened while the runtime was walking it. the err takes the same path, and the report still names where it was born. only the place it was reported has moved, one step later.

a file that isn't there fails the same way:

missing.kso
import "std/os"

os/read_file! "yesterday.txt" .> print
kanso run missing.kso
error[endpoint]: unhandled err reached the executor: "cannot read yesterday.txt: no such file"
  born in the entry at missing.kso:3

one distinction is worth pinning before you plan for a missing file. a failure born at the edge—the missing file, the absent argument—arrives after evaluation is over, and like any failure it passes every continuation waiting downstream. an outcome the program can anticipate is a value instead, and an arm takes it:

fallback.kso
import "std/os"

fn orders (file_not_found _)
  "no orders yet"

fn orders text
  "yesterday: {text}"

os/read_file "yesterday.txt" .> orders .> print
kanso play fallback.kso
no orders yet

that arm fires, and no rescue was needed. a file that is not there is an outcome the program can anticipate, so os/read_file answers text | file_not_found and dispatch picks the arm the way it picks any other.

the failure channel is for what the program did not plan for—a permission it does not have, a device that went away, bytes that are not text. those still go straight to the endpoint, with a message addressed to whoever ran the program, the one party who can do anything about it.

a caller who knows the file is there says so with a bang. os/read_file! path is insisting, and absence comes out of it as a failure like any other. it is the same choice foo[k] and foo[k]! make over a missing key: the ! decides whether absence is an answer or a failure.

a chain carries a failure past every step to the endpoint. one step is written to read it instead. rescue takes the effect and a callback, and the callback runs only when the effect failed—so the sample below insists, to have a failure for it to catch:

rescued.kso
import "std/os"

fn orders (err _)
  "no orders yet"

fn orders text
  "yesterday: {text}"

rescue (os/read_file! "yesterday.txt") orders .> print
kanso play rescued.kso
no orders yet

orders has an (err _) arm, and it fires here because rescue hands its callback the err out of its box, where an arm can read it. downstream of a dot, nothing hands a function a failure. a read that succeeded goes straight past rescue untouched, so the callback sees an err or nothing at all. os is a package of the standard library, so the failure is foreign to this file, which is what licenses the rescue.

rescue has two siblings, both ordinary two-argument functions taking the effect first. bind is the other side of the same rule—its callback sees the value and never the failure. annotate reads the failure channel like rescue and re-wraps whatever the callback answers, so it can say more about a failure and can never clear one. so rescue is the only way out of a failure, and a reader looking for where a program recovers has one word to search for.

each of the three has a fused spelling, and those are the forms most code wears: .> for bind, .! for annotate, .? for rescue. the program below is the one above with both steps written that way, and it prints the same line:

fused.kso
import "std/os"

fn orders (err _)
  "no orders yet"

fn orders text
  "yesterday: {text}"

os/read_file! "yesterday.txt" .? orders .> print
kanso play fused.kso
no orders yet

rescue (os/read_file! "yesterday.txt") orders and os/read_file! "yesterday.txt" .? orders are the same call. the fused form reads left to right with the rest of the chain, which is why a chain of several steps usually wears it, and appendix C gives the wrapping rule for a statement that runs past eighty columns.

err - always arrives, never uninvited
err

i work execution time too. a file that isn't there, an argument that wasn't passed—same me, same box, same railway. i ride past every step to the endpoint and name the line that bore me, unless a .? from another package takes me out first. you do not need a second error model for i/o, and kanso declines to sell you one.

the executor at the edge

so who actually performs? at the very edge of the toolchain sits the executor—the one component allowed to touch the world. in the interpreter it is a rust trait with one method per effect, and the shape of that trait is the shape of everything kanso can do to your machine:

src/eval/executor.rs
pub trait Executor {
    fn print(&mut self, text: &str);
    fn args(&mut self) -> Vec<String>;
    fn stdin(&mut self) -> Result<String, String>;
    fn read_file(&mut self, path: &str) -> Result<String, String>;
    fn write_file(&mut self, path: &str, content: &str) -> Result<(), String>;
}

impl Executor for RealExecutor {
    fn print(&mut self, text: &str) {
        println!("{text}");
    }

    fn read_file(&mut self, path: &str) -> Result<String, String> {
        std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))
    }
    // ...
}

this is the split sometimes called functional core, imperative shell, and here the language enforces it. your program—every function you write, every module you'll build in chapter 07—lives in the pure core. the shell is a short loop that walks the io and calls these trait methods. chapter 01 promised "two engines, one meaning," and this seam is why the promise is cheap to keep: kanso run compiles your program to a native binary while --plan and kanso test stay on the interpreter, and they can't disagree about what your program means, because the meaning is a value fixed before any executor gets involved.

testing without mocks

in most languages, testing effectful code means interception: patch the i/o call, record what would have happened, and hope the patch behaves like the real thing. in kanso there is nothing to intercept, because evaluation never does i/o. testing happens on two levels, and neither needs a mock.

the ground floor you already have. the logic in an effectful pipeline lives in pure functions—count_lines, shout, greet—and pure functions are tested by calling them, with the test_ constants from chapter 01:

core_test.kso
import "std/list"
import "std/text"

fn count_lines text
  list/count (text/chars text) (c -> c == "\n")

test_counts_lines = count_lines "matcha\nube\nhojicha\n" == 3

test_empty_text_has_no_lines = count_lines "" == 0
kanso test core_test.kso
test_counts_lines ... ok
test_empty_text_has_no_lines ... ok
2 passed, 0 failed

no file was faked, because no file was involved. the function under test takes a string, and where the program gets that string is not its concern. the file stays at the edge of the program, and the test never goes there.

the second level is the effects themselves, and it is how the toolchain tests itself. evaluating a program is pure, fast and free of side effects, and it gives the program's entire intent as a value. the interpreter makes this concrete: Executor is a trait, so alongside the real executor there is a scripted one that performs nothing and appends each effect to a transcript, feeding the program canned args, canned stdin, and an in-memory map of files:

src/eval/executor.rs
pub struct ScriptedExecutor {
    pub transcript: Vec<String>,
    pub script_args: Vec<String>,
    pub script_stdin: String,
    pub files: std::collections::HashMap<String, String>,
}

impl Executor for ScriptedExecutor {
    fn print(&mut self, text: &str) {
        self.transcript.push(format!("print {text:?}"));
    }

    fn read_file(&mut self, path: &str) -> Result<String, String> {
        self.transcript.push(format!("read_file {path:?}"));
        self.files.get(path).cloned().ok_or_else(|| format!("cannot read {path}"))
    }
    // ...
}

a rust test hands the program's io to the scripted executor and asserts on the transcript with plain data equality—assert_eq!(executor.transcript, ["print \"a\"", "print \"b\""]). no stubbing framework, no argument matchers, no "verify this was called once." the program said what it would do, and the test reads the saying:

cargo test scripted_executor
running 1 test
test eval::tests::scripted_executor_records_the_transcript ... ok

this book is tested the same way. every output panel is a golden file that a harness re-runs against the real toolchain, and the goldens hold because a kanso program's output is a function of its source, even, as chapter 06 shows, when the program is concurrent and rolls dice under a fixed seed.

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

when a program surprises you, reach for --plan before you reach for a debugger. the plan is what your program is; running it is merely what it does. most bugs are visible in the intent.

you can now split a program into the part that decides and the part that touches the world: io values on one side, one executor on the other. you can read .> (_ -> ...) as pure sequence and a bind into a named parameter as a value crossing into the future, and predict a --plan from the source before you run it. you can trace an execution-time failure to the endpoint and explain why no arm caught it. and you can test the logic of an effectful program without faking a single i/o call. chapter 06 is about two io values that need no order between them, which is where kanso's concurrency comes from.

exercises

  1. write a program that binds one print io to a name and uses it twice in the program, separated by a different print. predict the full --plan output on paper, then run it and compare.
  2. write a file copier: pipe read_file "flavors.txt" into a lambda that returns write_file "copy.txt" text. run it with --plan first and explain why the write step doesn't appear in the plan, then run it for real and confirm the copy exists.
  3. change first in welcome.kso to index xs[2] and run it with one argument, then with two. for each run, say whether the failure (if any) happened during evaluation or during execution, and quote the part of the message that told you.
  4. extract the pure function from shout.kso into a file of its own with two test_ constants—one for ordinary text, one for the empty string. decide what the empty-string result should be before you run kanso test, and reconcile if the toolchain disagrees.