Viscosity
Review
Part X · Drip in Rust · Day 37

147. The scanner in Rust

The TypeScript scanner read one character at a time. Rust won’t even let you index a string, and that turns out to be a gift: drip-rs scans raw bytes, and UTF-8 guarantees it can never land in the middle of a character by accident. Almost never.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why drip-rs scans a &[u8] alongside the &str, and write peek and advance over bytes.
  • Use UTF-8’s byte patterns to explain why comparing bytes with ASCII is safe.
  • Explain the continuation-byte skip in the scanner, and what it changes compared with the TypeScript VM.
  • Port the number scanner, including the rule that 1. is not a fraction.

One character, please

The TypeScript scanner’s helpers, from companion/ts/src/vm/scanner.ts:

private advance() {
  return this.source[this.current++];
}
private peek() {
  return this.atEnd()
    ? '\0' : this.source[this.current];
}

this.source[i] hands back a one-character string, and the scanner compares it with '(' or tests c >= '0' && c <= '9'. Write the same thing in Rust and it doesn’t compile (1.97, trimmed):

error[E0277]: the type `str` cannot be
  indexed by `{integer}`
  ^ string indices are ranges of
    `usize`

A str is UTF-8, where a character takes one to four bytes. “The character at index 5” would mean walking from the start to count them, so Rust refuses to pretend it is a cheap lookup[1]. You can slice a str with a byte range, or you can look at its bytes.

Two views of one text

drip-rs keeps both (from scanner.rs, trimmed):

pub struct Scanner<'src> {
    source: &'src str,
    bytes: &'src [u8],
    start: usize,
    current: usize,
    line: usize,
}

Scanner::new fills bytes with source.as_bytes(). That copies nothing: it is the same memory, typed as a slice of u8[2]. The scanner reads bytes to decide what each token is, and slices source to make the lexeme. The helpers become:

fn advance(&mut self) -> u8 {
    self.current += 1;
    self.bytes[self.current - 1]
}

fn peek(&self) -> u8 {
    self.bytes.get(self.current)
        .copied()
        .unwrap_or(b'\0')
}

get returns None past the end, so peek needs no separate atEnd test: it turns None into the NUL byte, as the TypeScript version did with '\0'. And b'(' is a byte literal, the u8 40, so the big match in scan_token reads almost like the TypeScript switch:

match c {
    b'(' => self.make(T::LeftParen),
    b'!' => self.make_if_equal(
        T::BangEqual, T::Bang),
    b'"' => self.string(),
    // ...
}
Definition

Byte scanning means reading source text as a &[u8] and comparing bytes with byte literals like b'(', instead of reading characters. A &str’s as_bytes() gives that view for free. Lexemes are still made by slicing the &str with byte ranges, &source[start..current].

Why bytes are safe

Scanning bytes would be a disaster if a byte of é could look like a quote. UTF-8 was designed so it can’t.

Intuition

UTF-8 is like a train with labelled carriages. A one-carriage train is plain ASCII. A longer train has an engine that says how many carriages follow, and every carriage is stamped “carriage”. No carriage can be mistaken for an engine, or for a one-carriage train.

In bits: an ASCII byte is 0xxxxxxx, 0 to 127. A character that needs more bytes starts with a lead byte, 110xxxxx, 1110xxxx or 11110xxx, and every byte after it is a continuation byte, 10xxxxxx. So every byte of a non-ASCII character is 128 or more, and comparing a byte with b'"' or b'\n' can only match a real quote or newline. Step through the scanner on s="é";é:

Step throughScanning bytes, not characters
Nine source bytes with start and current markers and the token being made0115s161=234"3195é₁4169é₂534"659;7195é₁8169é₂▲start▲currentpurple: bytes of a non-ASCII character (all ≥ 128)
Nine bytes, not seven characters: é is two bytes in UTF-8, 195 then 169. The scanner keeps two indices into bytes: start (where this token began) and current (the next byte to read).
1/8

That is why &self.source[self.start..self.current] never panics. Chapter 126 showed what slicing in the middle of é does. A token starts and ends only just after an ASCII byte, after a whole skipped character (next section), or at the end, and all three are character boundaries. In the middle of a string, current does pass through the inside of é, but nothing is sliced until the closing quote.

The one non-ASCII case

Outside a string, Drip has no use for é. The _ arm of the match reports it:

_ => {
    // Skip the rest of a multi-byte
    // UTF-8 character.
    while is_continuation(self.peek()) {
        self.current += 1;
    }
    self.error("Unexpected character.")
}

and the test is one mask, c & 0b1100_0000 == 0b1000_0000. Why bother? I removed the loop from a copy of the scanner and ran it on é: it gave two error tokens, one for byte 195 and one for byte 169. With the loop it gives one, like the TypeScript scanner, which sees é as a single UTF-16 code unit.

It is not a perfect match. An emoji such as 😀 is four UTF-8 bytes but two UTF-16 code units, so the TypeScript scanner returns two error tokens and drip-rs one. Nobody sees the difference: after the first error the compiler is in panic mode (chapter 34) and stays quiet until the next statement. I ran both VMs on a file with a 😀 on line 2, and both printed [line 2] Error: Unexpected character. and exited with 65.

The rest of the scanner

Everything else is the TypeScript logic with u8s. Numbers, from scanner.rs:

fn number(&mut self) -> Token<'src> {
    while self.peek().is_ascii_digit() {
        self.current += 1;
    }
    // `1.` is `1` then `.`
    if self.peek() == b'.'
        && self.peek_next()
            .is_ascii_digit()
    {
        self.current += 1;
        while self.peek()
            .is_ascii_digit() {
            self.current += 1;
        }
    }
    self.make(TokenKind::Number)
}

Strings loop to the closing b'"', counting b'\n's as they go. Comments run to the next b'\n'. Identifiers continue while bytes are ASCII letters, digits or _, then go through chapter 146’s keyword. Here is a cut-down scanner you can run, with the skip in it:

Try itA byte scanner with the UTF-8 skip
// A tiny byte scanner, adapted from
// drip-rs's scanner.rs.
struct Scan<'src> {
    src: &'src str,
    b: &'src [u8], // the same text
    st: usize,     // token start
    cur: usize,    // next byte
}

// The 2nd, 3rd or 4th byte of a UTF-8
// character looks like 10xxxxxx.
fn is_cont(c: u8) -> bool {
    c & 0b1100_0000 == 0b1000_0000
}

fn digit(c: u8) -> bool {
    c >= b'0' && c <= b'9'
}

impl<'src> Scan<'src> {
    fn peek(&self) -> u8 {
        let x = self.b.get(self.cur);
        x.copied().unwrap_or(b'\0')
    }

    fn bump(&mut self) -> u8 {
        self.cur += 1;
        self.b[self.cur - 1]
    }

    fn tok(&self, k: &str) -> String {
        let r = self.st..self.cur;
        let t = &self.src[r];
        format!("{k} {t}")
    }

    fn digits(&mut self) {
        while digit(self.peek()) {
            self.cur += 1;
        }
    }

    fn next(&mut self)
        -> Option<String> {
        while self.peek() == b' ' {
            self.cur += 1;
        }
        self.st = self.cur;
        if self.cur >= self.b.len() {
            return None;
        }
        let c = self.bump();
        if digit(c) {
            self.digits();
            let t = self.tok("Num");
            return Some(t);
        }
        if c == b'"' {
            while self.peek() != b'"' {
                self.cur += 1;
            }
            self.cur += 1;
            let t = self.tok("Str");
            return Some(t);
        }
        while is_cont(self.peek()) {
            self.cur += 1;
        }
        let a = self.st;
        let z = self.cur;
        Some(format!("Error {a}..{z}"))
    }
}

fn main() {
    let src = "42 \"é\" é 7";
    println!("{:?}", src.as_bytes());
    let mut s = Scan {
        src,
        b: src.as_bytes(),
        st: 0,
        cur: 0,
    };
    while let Some(t) = s.next() {
        println!("{}", t);
    }
}
Open in Rust Playground ↗
Under the hood

The standard library uses the same rule. str::is_char_boundary(i) is true when byte i starts a character or is the end, and the helper behind it is commented “equivalent to: b < 128 || b >= 192”[2]: not a continuation byte. clox scans a C string, which is bytes too, so here drip-rs is closer to clox than the TypeScript port was[3]. The TypeScript scanner counts UTF-16 code units, while clox and drip-rs count bytes, and they agree because everything Drip’s grammar cares about is ASCII.

Your turn

ExerciseScan a number

Port the number scanner to bytes. number starts at self.cur: set self.st there, read the digits, then a fractional part only if a . is followed by a digit (so 1. is the number 1 and a dot, as in the TypeScript scanner). Leave cur just past the number, and return the lexeme as a slice of src.

fn digit(c: u8) -> bool {
    c.is_ascii_digit()
}

struct Scan<'src> {
    src: &'src str,
    b: &'src [u8],
    st: usize,
    cur: usize,
}

impl<'src> Scan<'src> {
    fn new(src: &'src str) -> Self {
        let b = src.as_bytes();
        Scan { src, b, st: 0, cur: 0 }
    }

    /// The next byte (NUL at the end).
    fn peek(&self) -> u8 {
        let x = self.b.get(self.cur);
        x.copied().unwrap_or(b'\0')
    }

    /// The byte after that, or NUL.
    fn peek_next(&self) -> u8 {
        let i = self.cur + 1;
        let x = self.b.get(i);
        x.copied().unwrap_or(b'\0')
    }

    /// Scan the number at `cur`, and
    /// return its lexeme.
    fn number(&mut self) -> &'src str {
        todo!()
    }
}
Open in Rust Playground ↗
Extra practiceSkip a whole character

Write the scanner’s UTF-8 skip as two functions. is_cont(c) is true for a continuation byte (10xxxxxx). char_end(b, i): byte i starts a character; return the index just past that character, by stepping over the continuation bytes after it.

/// Is `c` a continuation byte, the 2nd,
/// 3rd or 4th byte of a character?
fn is_cont(c: u8) -> bool {
    todo!()
}

/// The index just past the character
/// that starts at byte `i`.
fn char_end(b: &[u8], i: usize)
    -> usize {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • Rust won’t index a str by integer. drip-rs scans bytes: it keeps bytes: &[u8] (from as_bytes(), free) beside source: &str.
  • peek uses get(..).copied().unwrap_or(b'\0'), and the match is on byte literals like b'('.
  • In UTF-8 every byte of a non-ASCII character is ≥ 128, and continuation bytes look like 10xxxxxx. So ASCII comparisons are safe, and lexeme slices always land on character boundaries.
  • For an unexpected non-ASCII character the scanner skips its continuation bytes, giving one error per character.
  • Numbers, strings, comments and identifiers are the TypeScript logic on u8s.

Check your understanding

0/6
  1. 1.

    Why does drip-rs keep both source: &str and bytes: &[u8]?

  2. 2.

    What is "é".len() in Rust?

  3. 3.

    Which byte value can be part of a non-ASCII character in UTF-8?

  4. 4.

    The scanner meets byte 195 outside a string. What does drip-rs do?

  5. 5.

    How does peek() behave at the end of the source?

  6. 6.

    How does drip-rs scan 1.?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] The Rust Project Developers. Primitive type str (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/str/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/primitive.str.html (checked 2026-10-31)
  3. [3] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)