chapter 05

effects are io

print has been doing nothing all book. every kanso function is pure—evaluating print "hi" performs no i/o. it returns an io, a value like any other, describing the printing it intends. your entire program is one big pure computation whose final answer is main: one io describing everything the program means to do. the runtime receives that value and walks it. evaluation decides; the runtime performs.

you can see the seam directly, because an io is data, and data can be rendered instead of executed. >>—the wall—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
brewing
steeping
pouring
kanso run brew.kso
brewing
steeping
pouring

--plan falls out of the design. the plan already exists as a value—the flag just prints it instead of handing it to the executor. each print step carries the source span it came from, which is where the provenance comments come from. nothing else in this chapter is new machinery either. everything is a consequence of one sentence: an effect is a value.

an io is a value

follow that sentence around. first: if main is just a constant, a program whose main is a plain number should be legal. it is. it computes the number, tells no one, and exits cleanly—there is no io to walk, so the runtime has nothing to do:

quiet.kso
6 * 7
kanso run quiet.kso --plan

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
kon kon
kon kon
kon kon
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, the same way chapter 04 treated an unreceived err:

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"
       ^

four small programs, one lesson: 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 when you pipe an io into a function, kanso wires the function to receive the value once it exists. piping into an io is bind:

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

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

pub play = io/read_file "flavors.txt" . count_lines . print
kanso run count.kso
flavors.txt has 3 lines
kanso run count.kso --plan
flavors.txt has 3 lines

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." that's the difference between the wall and the dot in one screenshot: >> sequences io values that don't pass data (the plan lists every step up front), while . threads a value through, so later steps are functions waiting for input. together they are the complete vocabulary of "before" in kanso—everywhere else, order belongs to the runtime, which is chapter 06's whole 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, no effect type to declare—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.

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 run 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 doing real work, and it is required. tax 8 on its own is a call, and a call short of every arm is an error — so a bare application can never mean "wait for more". with overloading, whether an application has finished cannot be read off the text: if tax also had a one-argument arm, tax 8 would be a completed call, and the partial you meant would be unreachable. & is how you say which one you meant, and it means the answer cannot change when somebody adds an arm tomorrow.

& 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 run running.kso
still waiting: <fn>
called: 270

so the two are complements: & gives without running, () runs without giving. A value that still wants arguments cannot be run this way — () 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 a wall sequences a write before a read of the same file, and a dot hands the read's contents onward:

save.kso
import "std/io"

order = "one taiyaki, extra custard"
io/write_file "order.txt" order >> io/read_file "order.txt" . print
kanso run save.kso --plan
one taiyaki, extra custard
kanso run save.kso
one taiyaki, extra custard

the round trip proves the sequencing: the read found the file because the wall put the write before it. one line of source holds both operators doing their one job each—>> orders two effects that share no data, . moves the read's result into print.

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/io"

fn first xs
  xs[1]!

fn greet name
  "irasshaimase, {name}"

io/args . first . greet . print
kanso play welcome.kso -- clay
error[endpoint]: unhandled err reached the executor: "missing index 1"
  born in first at welcome.kso:4
shout.kso
import "std/io"

pub play = io/stdin . shout . print

fn shout text
  "{text}!!"
printf 'hello from a pipe' | kanso run shout.kso
hello from a pipe!!

that is the whole i/o vocabulary of part i: print, ambient, and io/read_file, io/write_file, io/args, io/stdin behind one import. five verbs, zero new syntax, because the composition operators don't care what they compose.

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/io"

fn first xs
  xs[1]!

fn greet name
  "irasshaimase, {name}"

io/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 rides the same railway, and the diagnostic still names where it was born. the endpoint has moved one stop later, and that is the whole difference.

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

missing.kso
import "std/io"

io/read_file "yesterday.txt" . print
kanso run missing.kso
error[endpoint]: unhandled err reached the executor: "cannot read yesterday.txt: no such file or unreadable"

one distinction is worth pinning before you reach for a chapter 04 arm. an arm receives an err during evaluation, when the err is a value flowing through your functions. an err born at the edge—the missing file, the absent argument—arrives after evaluation is over, and it does not knock at the continuations waiting downstream, even ones with an arm ready:

fallback.kso
import "std/io"

fn orders (err _)
  "no orders yet"

fn orders text
  "yesterday: {text}"

pub play = io/read_file "yesterday.txt" . orders . print
kanso run fallback.kso
error[endpoint]: unhandled err reached the executor: "cannot read yesterday.txt: no such file or unreadable"

the err arm on orders is real, and it would fire on any err that flowed to it as a value during evaluation—chapter 04's pattern, unchanged. but the read's failure is the executor reporting that the world refused the program's contract, and the executor does not resume your pipeline to ask how you feel about it. the failure goes straight to the endpoint, message addressed to whoever ran the program—the one party who can actually put the file back. an execution failure is operational, not logical, and kanso declines to blur the two.

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 railway, but i board past the last arm. at the edge nobody dispatches on me, so i ride straight to the endpoint and name the line that bore me. 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.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 classic functional-core, imperative-shell split, enforced by construction rather than discipline. 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

the architecture buys this, and it is the reason the whole design exists. in most languages, testing effectful code means interception: monkey-patch the i/o call, record what would have happened, hope the patch is faithful to the real thing. in kanso there is nothing to intercept, because evaluation never does i/o. the testing story has two floors, both mock-free.

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 the fact that main pipes it from read_file is not the function's business. the design pushed the file dependency to the edge, and the test simply doesn't follow it there.

the second floor is the effects themselves. a test evaluates main—pure, fast, no side effects—and gets 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.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 test hands main'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

and this book eats the same cooking. every output panel in every chapter is a golden file that a harness re-runs against the real toolchain, and the goldens hold because a kanso program's transcript is a function of its source—even, as chapter 06 will show, when the program is concurrent and rolling dice.

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 any program into the part that decides and the part that touches the world, and say precisely where the boundary sits: io values on one side, one executor on the other. you can read >> as pure sequence and . 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 takes the one thing this chapter kept quiet—what happens when two io values don't need an order—and turns it into concurrency for free.

exercises

  1. write a program that binds one print io to a name and splices it into main twice, 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.