Odin and Me · Chapter 3

Chapter 3: Talking to the terminal

This chapter:

  • Read user input, using core:bufio.
  • Parse that text into a number, and handle the case where it isn't one.
  • Format output with fmt.printf.
  • Write an interactive program: a temperature converter.

Where we left off: Chapter 2 gave you a feel for the Odin language. Every program so far has only ever produced output, but now you'll deal with user input too.


3.1 Scanner

Odin's standard library provides us with a way to easily get user input: a Scanner that reads one line at a time, from core:bufio, wrapped around standard input.

import "core:bufio"
import "core:os"

scanner: bufio.Scanner
bufio.scanner_init(&scanner, os.to_reader(os.stdin))
defer bufio.scanner_destroy(&scanner)

Three things happening here:

  • bufio.Scanner is a struct you own: you declare it as a local variable.
  • os.to_reader(os.stdin) turns the standard input file handle into the generic io.Reader interface bufio.scanner_init expects. os.stdin is Odin's name for the input stream your program's user is typing into.
  • defer bufio.scanner_destroy(&scanner): schedule the scanner's cleanup for whenever the enclosing procedure returns. In Odin, you are responsible for cleaning up resources, as we'll see more later on. Defer is definitely your best friend for this.

Odin note: &scanner everywhere. bufio.scanner_init and bufio.scanner_destroy both take a pointer to your Scanner (^bufio.Scanner), so they can modify the struct you declared. &scanner takes that pointer. We'll pass scanners around by pointer throughout our code: passing the whole struct by value would hand a procedure its own private copy, which isn't what you want when a scanner needs to keep its place in the input.

3.2 Reading one line

Once you have a scanner, reading is two calls: scanner_scan asks for the next line and reports whether there was one (you're at the end of input otherwise); scanner_text gives you that line as a string.

fmt.print("Enter your name: ")
if bufio.scanner_scan(&scanner) {
	name := bufio.scanner_text(&scanner)
	fmt.printfln("Hello, %s!", name)
}

scanner_scan already strips the trailing newline for you: the line you get back is just the text the user typed. It's still worth trimming any other stray whitespace they might have typed around it (a leading space, a trailing tab) with core:strings:

import "core:strings"

line := strings.trim_space(bufio.scanner_text(&scanner))

fmt.print (no newline) rather than fmt.println is deliberate here: you want the prompt and the user's typed answer to end up on the same line, the way a terminal program normally looks.

Gotcha: scanner_scan returns false at end of input, not an error you have to unwrap. If the user closes the input stream (pressing Ctrl-D on macOS/Linux, Ctrl-Z then Enter on Windows, or piping in a file that runs out), scanner_scan simply returns false. Always check it before calling scanner_text: reading past the end of input isn't a crash, but proceeding as if you got a real line will hand you stale or empty data.

3.3 Parsing a number from text

Whatever the user typed is a string: "21.5", not the number 21.5. Converting it is core:strconv, and it follows the pattern you'll see everywhere from here on: a value, and a bool telling you whether it's any good.

import "core:strconv"

value, ok := strconv.parse_f64(line)
if ok {
	fmt.printfln("Got %.1f", value)
} else {
	fmt.println("That wasn't a number.")
}

strconv.parse_int(line) is the equivalent for whole numbers. Neither one panics or raises an exception on bad input: they hand you back ok = false and leave it entirely up to you what happens next. That "up to you" is the point: Chapter 11 turns this pattern into a proper reusable helper, but even here, in your very first interactive program, you can start to build the habit of checking ok rather than trusting the input blindly.

Odin note: (value, ok) is everywhere. You saw multiple return values in Chapter 2 as a language feature; here's the idiom they exist to serve. Odin has no exceptions. Anything that might fail, parsing text, opening a file (Chapter 8), finding something in a collection (Chapter 9), returns an extra bool (or, where the reason for failure matters, a proper Error value) instead.

3.4 Formatted output

You've used fmt.printf's verbs already: %s for a string, %d for an integer. A few more:

  • %v: Odin's "just show me something sensible" default; works on almost any value.
  • %.1f: a float to one decimal place.
  • %7d: an integer padded out to at least 7 characters wide.

That last one hides a small surprise if you've used C's printf before. In C, a bare width like %7d pads with spaces. In Odin, it pads with zeros:

fmt.printfln("[%7d]", 42)   // [0000042]

To get the space-padding you probably wanted, add an explicit space flag before the width:

fmt.printfln("[% 7d]", 42)   // [     42]

Gotcha: Odin's default integer/float width padding is zero, not space. If you want a right-aligned column of numbers with space padding (which you will, building a table in a moment), write the width as % 7d or % 10.1f, space flag first. It reads a little oddly the first few times, but you get used to it quickly.

3.5 The program: a temperature converter

Let's put reading, parsing and a retry loop together to build a temperature conversion utility.

// convert.odin
package main

import "core:bufio"
import "core:fmt"
import "core:os"
import "core:strconv"
import "core:strings"

read_line :: proc(scanner: ^bufio.Scanner) -> (line: string, ok: bool) {
	if !bufio.scanner_scan(scanner) {
		return "", false
	}
	return strings.trim_space(bufio.scanner_text(scanner)), true
}

read_celsius :: proc(scanner: ^bufio.Scanner) -> f64 {
	for {
		fmt.print("Temperature in Celsius: ")
		line, ok := read_line(scanner)
		if !ok {
			os.exit(1)   // input closed on us, nothing sensible left to do but stop
		}
		value, parsed := strconv.parse_f64(line)
		if parsed {
			return value
		}
		fmt.println("Please enter a number, e.g. 21.5")
	}
}

main :: proc() {
	scanner: bufio.Scanner
	bufio.scanner_init(&scanner, os.to_reader(os.stdin))
	defer bufio.scanner_destroy(&scanner)

	celsius := read_celsius(&scanner)
	fahrenheit := celsius*9.0/5.0 + 32.0
	fmt.printfln("%.1f°C is %.1f°F", celsius, fahrenheit)
}

Run it and try both a bad answer and a good one:

$ odin run convert.odin -file
Temperature in Celsius: hot
Please enter a number, e.g. 21.5
Temperature in Celsius: 21.5
21.5°C is 70.7°F

Read read_celsius: it's a for {} infinite loop (§2.5's third shape) that only ever leaves through the return value inside the if parsed: the "keep asking until you get a sensible answer" pattern in action. read_line exists as its own small procedure because you'll want to reuse this "read a trimmed line, or tell me input ran out" behaviour again in Chapter 6, Chapter 8, and throughout Muninn.

3.6 A second pass: a table

Now let's print a small conversion table, ten degrees at a time, right-aligned.

// table.odin
package main

import "core:fmt"

main :: proc() {
	fmt.println("Celsius  Fahrenheit")
	for c := 0; c <= 100; c += 10 {
		f := f64(c)*9.0/5.0 + 32.0
		fmt.printfln("% 7d  % 10.1f", c, f)
	}
}
Celsius  Fahrenheit
      0        32.0
     10        50.0
     20        68.0
     ...
    100       212.0

Notice the space flag on both % 7d and % 10.1f; leave it off and watch the column fill with zeros instead, exactly as §3.4 highlighted.

Checkpoint

convert.odin reads a Celsius temperature, re-prompting on bad input, and prints the Fahrenheit equivalent. table.odin prints a right-aligned conversion table. You have now read input, parsed it and handled failure without crashing.

Exercises

  1. Extend convert.odin to also print the Kelvin equivalent (kelvin = celsius + 273.15) on the same line as the Fahrenheit result.
  2. Write the reverse converter: prompt for a Fahrenheit temperature and print its Celsius equivalent, reusing read_line from convert.odin.
  3. read_celsius calls os.exit(1) the moment input runs out. What would go wrong if you instead just return 0 from that branch and let main carry on as if the user had typed 0? (Solution sketch: try it by piping in genuinely empty input: printf "" | odin run convert.odin -file, with os.exit(1) first, then with a return 0 in its place, and compare what each version prints. Silently substituting a fake value hides the fact that nothing was ever actually entered.)
  4. Modify table.odin to print Celsius from -20 to 40 in steps of 5, instead of 0 to 100 in steps of 10. Watch what a negative value does to the % 7d column: does it still line up?
  5. What does strconv.parse_f64("21.5abc") return? Does ok come back true or false? Write a one-line test program to find out, then compare it against strconv.parse_f64_prefix, which returns a third value: how many characters it actually managed to read as a number. (Solution sketch: parse_f64 is strict: it demands the whole string be a valid number, so ok comes back false here; parse_f64_prefix happily reads the 21.5 and tells you it stopped after 4 characters, ok = true. read_celsius in §3.5 wants the strict one: a user who typed 21.5abc almost certainly mistyped something, and silently accepting the 21.5 prefix would hide that from them.)

Further reading