Skip to content
Menu
Contents

A command-line program

Tutorial

Read from stdin, write to stdout, and ship a single .wasm file.

What we build

A small running-total calculator called sum. It reads one line at a time. A number is added to the total. A few words do special things.

printf '5\n10\nabc\ntotal\nquit\n' | bert sum.bert
Output
Hello!
5
15
not a number: abc
15
bye

Along the way you will use WasiCli to read and print, a function that calls itself to repeat, tests for the logic, and bert build to make a file you can run anywhere.

You will also meet Wasmtime, a free program that runs WebAssembly files. To build a program means to turn your Bert code into such a file, once, so you can run it later without Bert.

This tutorial makes a plain WebAssembly file. That is what Bert makes unless you ask for something else.

It assumes you have bert and Wasmtime installed from the Download page, as the guide's install section describes.

Set up

Make a folder with an empty bert.toml, as in the guide:

mkdir sum && cd sum
touch bert.toml

All the code below goes in a file called sum.bert. We will grow it step by step. To test an early step, put it in its own file such as echo.bert.

Read a line

A command-line program has two channels. Standard input (stdin) is what comes in, such as what you type or pipe in. Standard output (stdout) is what the program prints.

WasiCli.readLine() reads one line from stdin. It can fail: when stdin is closed, there is no next line. So it gives back a Result, the Ok or Err value you met in the guide: either Ok(line) when there was a line, or Err(EndOfInput) when there was not. The Errors chapter shows how results work.

echo.bert
external export {
  _start : () -> Unit
  _start = () -> match WasiCli.readLine() {
    Ok(line) -> WasiCli.print(String.concat(line, "\n"))
    Err(EndOfInput) -> WasiCli.print("nothing to read\n")
  }
}
echo "hi there" | bert echo.bert
bert echo.bert < /dev/null
Output
hi there
nothing to read

The line comes back without its line break. An empty line is Ok(""), which is different from Err(EndOfInput). That lets a program tell "the user pressed Enter" from "there is no more input".

WasiCli.print writes exactly what you give it, so we add the \n ourselves.

Keep reading

Bert has no loops, so a function calls itself. Here repeat reads a line, prints it, and starts over:

loop.bert
repeat = () -> match WasiCli.readLine() {
  Ok(line) -> {
    _ = WasiCli.print(String.concat(line, "\n"))
    repeat()
  }
  Err(EndOfInput) -> WasiCli.print("bye\n")
}

external export {
  _start : () -> Unit
  _start = () -> repeat()
}
printf 'a\nb\nc\n' | bert loop.bert
Output
a
b
c
bye

Two things to notice:

  • _ = WasiCli.print(…) prints, then throws away what print gives back. In a { } block, every line but the last must give a name to something. _ is the name for "I do not care". The Start chapter uses it too.
  • repeat() is the last thing the function does. So Bert can start the function over in the space it already uses, instead of taking more. That is why this can run for a million lines, or forever, without running out of memory.

Do the work

Now the actual calculator. A good habit: put the thinking in a function that does not read or print anything, and keep the reading and printing in a thin shell around it. We call the thinking part step.

step takes the current total and a line. It answers with a Reply: either Say a message and keep going with a new total, or Stop.

sum.bert
Reply = [Say(Int, String) | Stop(String)]

step : (Int, String) -> Reply
step = (total, line) -> match String.trim(line) {
  "quit" -> Stop("bye")
  "total" -> Say(total, Int.toString(total))
  text -> match Int.fromString(text) {
    Ok(n) -> Say(total + n, Int.toString(total + n))
    Err(_) -> Say(total, String.concat("not a number: ", text))
  }
}

It matches on the trimmed line. A string pattern like "quit" is checked first. Anything else lands in text, which we try to turn into a number with Int.fromString. That gives an Ok or Err again, so we match once more.

Now the shell around it. Add this below step:

say = (text) -> WasiCli.print(String.concat(text, "\n"))

loop = (total) -> match WasiCli.readLine() {
  Err(EndOfInput) -> say("bye")
  Ok(line) -> match step(total, line) {
    Stop(message) -> say(message)
    Say(newTotal, message) -> {
      _ = say(message)
      loop(newTotal)
    }
  }
}

external export {
  _start : () -> Unit
  _start = () -> loop(0)
}

The total is not stored anywhere. Names never change in Bert, so loop passes the new total to its next call: loop(newTotal). Each round has its own total.

printf '5\n10\nabc\ntotal\n  7  \nquit\n99\n' | bert sum.bert
Output
5
15
not a number: abc
15
22
bye

The 99 after quit is never read. The program stopped first.

Test it

Because step does not read or print anything, you can test it by calling it. Put tests at the bottom of sum.bert:

test "a number is added to the total" {
  expect(step(10, "5"), \(r) -> r == Say(15, "15"))
}

test "spaces around a number are ignored" {
  expect(step(0, "  7  "), \(r) -> r == Say(7, "7"))
}

test "text that is not a number leaves the total alone" {
  expect(step(10, "abc"), \(r) -> r == Say(10, "not a number: abc"))
}

test "quit stops" {
  expect(step(10, "quit"), \(r) -> r == Stop("bye"))
}
bert test sum.bert
Output
running 4 tests from sum.bert

  ✓ a number is added to the total  (1 assertion)
  ✓ spaces around a number are ignored  (1 assertion)
  ✓ text that is not a number leaves the total alone  (1 assertion)
  ✓ quit stops  (1 assertion)

4 passed, 0 failed  (4 assertions)

Each test compares the Reply with the one it expects. == compares variants by what is in them, so Say(15, "15") only equals another Say(15, "15").

Arguments and exit codes

A command-line program can also get arguments: words you type after the program's name when you start it, like Ada in wasmtime run sum.wasm Ada. WasiCli.args() returns them as a list of strings. The first item is the program itself, so the first real argument is at position 1.

Arguments only reach the program when you run the built file with Wasmtime, in the next step. bert sum.bert Ada does not pass Ada on: it stops with "unexpected extra argument". So write this step now and try it in the next one.

Let sum greet you by name. Add this above loop:

greet = (args) -> match List.nth(args, 1) {
  Ok(name) -> say(String.concat("Hello, ", name))
  Err(_) -> say("Hello!")
}

List.nth gives an Ok or an Err, so a missing argument is handled, not a crash. Call it first in _start:

external export {
  _start : () -> Unit
  _start = () -> {
    _ = greet(WasiCli.args())
    loop(0)
  }
}

Last, quit should leave with an exit code: a number the program hands back to the terminal when it ends. 0 means all went well, and any other number means something went wrong. WasiCli.exit(0) ends the program with code 0. Change the Stop arm in loop:

    Stop(message) -> {
      _ = say(message)
      WasiCli.exit(0)
    }

exit ends the program, so it can stand in either arm.

Build a .wasm file

So far bert sum.bert compiled and ran the program in one go. To keep the result, build it:

bert build sum.bert --output sum.wasm

You get sum.wasm, a file of about 10 KB. Bert is no longer needed to run it. Any program that runs WebAssembly command-line programs can run it, such as Wasmtime:

printf '1\n2\ntotal\n' | wasmtime run sum.wasm Ada
Output
Hello, Ada
1
3
3
bye

The name after the file reaches the program as an argument, so it says "Hello, Ada". With no name it says "Hello!". The final bye comes from stdin running out.

The whole program

sum.bert
Reply = [Say(Int, String) | Stop(String)]

step : (Int, String) -> Reply
step = (total, line) -> match String.trim(line) {
  "quit" -> Stop("bye")
  "total" -> Say(total, Int.toString(total))
  text -> match Int.fromString(text) {
    Ok(n) -> Say(total + n, Int.toString(total + n))
    Err(_) -> Say(total, String.concat("not a number: ", text))
  }
}

say = (text) -> WasiCli.print(String.concat(text, "\n"))

greet = (args) -> match List.nth(args, 1) {
  Ok(name) -> say(String.concat("Hello, ", name))
  Err(_) -> say("Hello!")
}

loop = (total) -> match WasiCli.readLine() {
  Err(EndOfInput) -> say("bye")
  Ok(line) -> match step(total, line) {
    Stop(message) -> {
      _ = say(message)
      WasiCli.exit(0)
    }
    Say(newTotal, message) -> {
      _ = say(message)
      loop(newTotal)
    }
  }
}

external export {
  _start : () -> Unit
  _start = () -> {
    _ = greet(WasiCli.args())
    loop(0)
  }
}

test "a number is added to the total" {
  expect(step(10, "5"), \(r) -> r == Say(15, "15"))
}

test "spaces around a number are ignored" {
  expect(step(0, "  7  "), \(r) -> r == Say(7, "7"))
}

test "text that is not a number leaves the total alone" {
  expect(step(10, "abc"), \(r) -> r == Say(10, "not a number: abc"))
}

test "quit stops" {
  expect(step(10, "quit"), \(r) -> r == Stop("bye"))
}

Ideas to try

  • Add a reset command that sets the total back to 0.
  • Read the whole input at once with WasiCli.readAll() and add up every line with String.split and List.foldl.
  • Read an environment variable with WasiCli.env("NAME"). It gives Err(NotFound) when the variable is missing.
  • Make a Float version. Remember that Float.fromString reads numbers the way JSON writes them.