126. Slices and strings
A scanner spends its whole life pointing at pieces of the source. Rust gives you a type for “a piece of something someone else owns”, and the scanner in the Rust Drip VM never copies a byte of text.
- Take a slice of an array or a string with a range, and say what a slice holds.
- Write functions that accept any slice, of any length.
- Choose between String and &str, and convert between them.
- Explain why a slice keeps its owner borrowed, and why string slices count bytes.
A token is a piece of the source
In chapter 28 your scanner made a token for every word of a Drip program, and each token carried its lexeme: the exact text it came from. The TypeScript scanner gets that text with
lexeme: source.slice(start, current)
which hands you a new string value. But the text already exists. It is
sitting right there in source, between start and current. All a
token really needs is a way to say “those bytes, over there”.
Rust has a type for exactly that, and it builds on the last chapter: it is a borrow of part of a value.
A highlighter pen on a printed page. You don’t photocopy the sentence you care about. You mark where it starts and how far it goes, and the page stays where it is, owned by whoever printed it.
Slices of arrays
Write a range in square brackets after a borrow:
let a = [10, 20, 30, 40, 50, 60];
let mid = &a[1..4]; // 20, 30, 40
let head = &a[..2]; // 10, 20
let tail = &a[3..]; // 40, 50, 60
The range 1..4 starts at 1 and stops before 4. Leave out a side and
it runs to the start or the end. Step through what each one points at:
The real payoff is in function parameters. A function that takes
&[i32] doesn’t care how long the array is, or whether it is getting
all of it:
fn sum(xs: &[i32]) -> i32 {
let mut total = 0;
let mut i = 0;
while i < xs.len() {
total += xs[i];
i += 1;
}
total
}
sum(&a), sum(&a[1..4]) and sum(&a[3..]) all work. A borrow of a
whole array turns into a slice automatically.
Ask for a range that isn’t there, like &a[2..9], and the program stops
at run time with a message (Rust calls this a panic):
range end index 9 out of range for
slice of length 6
fn sum(xs: &[i32]) -> i32 {
let mut total = 0;
let mut i = 0;
while i < xs.len() {
total += xs[i];
i += 1;
}
total
}
fn main() {
let a = [10, 20, 30, 40, 50, 60];
let mid = &a[1..4];
println!("len {}", mid.len());
println!("mid {}", sum(mid));
println!("all {}", sum(&a));
println!("tail {}", sum(&a[3..]));
}String and &str
You met String in chapter 124. It owns its text on the heap, and it can
grow. A string slice, written &str, is a view of some text that
somebody else owns. That text might live in a String, or in the
compiled program itself: a string literal like "print" is a &str,
which is why literals can’t be changed[1].
let mut own = String::from("let");
own.push_str(" x"); // String: grows
let view: &str = &own; // whole String
let kw = &own[0..3]; // "let"
let lit = "print"; // &str literal
let copy = lit.to_string(); // new String
Converting from String to &str is free: it is just a borrow. Going
the other way, String::from(x) or x.to_string(), copies the bytes
into a fresh allocation.
So which should a function take? Prefer &str. A &str parameter
accepts literals and slices, and when you pass &own for a String,
Rust converts the &String to a &str for you[1].
fn lexeme(
src: &str,
start: usize,
end: usize,
) -> &str {
&src[start..end]
}
String vs &str: a String is an owned, growable buffer of
UTF-8 text on the heap. A &str is a borrowed string slice: a
pointer and a length into UTF-8 text owned by something else, such as a
String or the program binary. Take &str in parameters. Make a
String when you need to own or grow text.
fn lexeme(
src: &str,
start: usize,
end: usize,
) -> &str {
&src[start..end]
}
fn main() {
let s = String::from("let x = 10;");
let name = lexeme(&s, 4, 5);
let num = lexeme(&s, 8, 10);
println!("{name} and {num}");
let lit = "print";
println!("{}", lexeme(lit, 0, 2));
let mut own = String::from(name);
own.push_str("_2");
println!("{own} {}", own.len());
}Slices keep their owner borrowed
A slice is a borrow, so last chapter’s rule applies. This doesn’t compile:
let mut s = String::from("let x;");
let word = &s[0..3];
s.clear();
println!("{word}");
clear needs to change s, so it takes a mutable borrow, and word is
still reading. The error is the same E0502 (real rustc 1.97 output,
trimmed):
error[E0502]: cannot borrow `s` as
mutable because it is also borrowed
as immutable
In TypeScript, the token’s lexeme would be its own string and the question never comes up. In Rust a slice is cheaper, and the price is that the source has to stay put while tokens point into it. For a scanner that is exactly right: the source doesn’t change while you scan it.
String indices count bytes, not characters. "héllo".len() is 6,
because é takes two bytes in UTF-8. Slice in the middle of one and the
program panics (real output, trimmed):
end byte index 2 is not a char boundary;
it is inside 'é' (bytes 1..3 of string)This is different from TypeScript, whose length counts UTF-16 code
units (chapter 10). Neither one counts “characters”.
Here is the token type from the Rust Drip scanner,
companion/rust/src/scanner.rs, lightly trimmed:
pub struct Token<'src> {
pub kind: TokenKind,
pub lexeme: &'src str,
pub line: usize,
}and here is how it makes one:
lexeme: &self.source[
self.start..self.current],Ignore the 'src for now: it is a lifetime, the subject of chapter 133,
and it tells the compiler that a token can’t outlive the source it
points into. The file’s own comment says the result: a token borrows its
lexeme straight out of the source text, so scanning never allocates. The
scanner also keeps bytes: &[u8], a byte view of the same text, because
every character that matters to Drip is ASCII.
Slices matter for Viscosity too. In a struct-of-arrays layout every x
is in one array, and &xs[from..to] hands a chunk of that column to any
function that takes &[f64], with no copying. You will lean on that in
chapter 140.
Your turn
Write take_ident(src), which returns the identifier at the very start of src: the longest run of ASCII letters, digits and _ (use the is_ident helper). Return a **slice of src**, not a new String. If src starts with anything else, return an empty slice.
fn is_ident(c: u8) -> bool {
c.is_ascii_alphanumeric()
|| c == b'_'
}
fn take_ident(src: &str) -> &str {
// your code here
""
}A struct-of-arrays layout keeps every particle’s x in one array. Write sum_range(xs, from, to) that adds up xs[from..to] by slicing first, then summing the slice.
fn sum_range(
xs: &[f64],
from: usize,
to: usize,
) -> f64 {
0.0
}What you learned
- A slice
&[T]is a borrowed window on a run of elements: a pointer and a length.&a[1..4]excludes its end, and every access is bounds-checked. - Functions that take slices work on whole arrays and on parts of them.
Stringowns growable UTF-8 text.&strborrows text that lives elsewhere, including string literals. Take&strin parameters.- A slice keeps its owner borrowed, so the owner can’t be changed while the slice is in use.
- String lengths and indices are in bytes.
Next: structs. You have seen one already, Token. Time to write your
own, and give them methods.
Check your understanding
0/6- 1.
let a = [5, 6, 7, 8];What is&a[1..3]? - 2.
What is stored in a
&[i32]slice value? - 3.
Which function signature accepts
"lit",&my_stringand&my_string[2..]all three? - 4.
Does this compile?
let mut s = String::from("let x;"); let word = &s[0..3]; s.push_str(" y"); println!("{word}"); - 5.
In
let lit = "print";, where do the five bytes live? - 6.
Why does the Rust Drip scanner make each lexeme a
&strslice of the source?
References
- [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] The Rust Reference contributors. Slice types. The Rust Reference. doc.rust-lang.org/reference/types/slice.html (checked 2026-10-31)
- [3] The Rust Reference contributors. Dynamically sized types. The Rust Reference. doc.rust-lang.org/reference/dynamically-sized-types.html (checked 2026-10-31)