5. Strings¶
Text is everywhere, so a language lives or dies by how pleasant its strings are to work
with. Sushi strings are UTF-8 by default (a full Unicode galaxy, not just ASCII), they are
immutable (every operation returns a new string), and they come with two literal styles
and a tidy interpolation syntax. If you know Python's f-strings or Java's
String.format, you'll feel at home quickly — and you'll find Sushi a little stricter
about which quotes do what.
Two kinds of quotes¶
Sushi has two string literal syntaxes, and the difference is meaningful:
- Double quotes
"..."— support interpolation (embedding{expr}). - Single quotes
'...'— plain literals, no interpolation.
Both support the same escape sequences: \n (newline), \t (tab), \\ (backslash),
\' (single quote), \" (double quote), and the numeric forms \xNN / \uNNNN.
fn main() i32:
# Double quotes support interpolation; single quotes are plain literals.
let string greeting = "Mostly Harmless"
let string literal = 'no {interpolation} happens here'
println(greeting)
println(literal)
# Escape sequences work in both quote styles.
println("tab\there\nand a new line")
println("a backslash: \\")
println('a quote inside singles: can\'t')
println("a quote inside doubles: \"quoted\"")
return Result.Ok(0)
Output:
Mostly Harmless
no {interpolation} happens here
tab here
and a new line
a backslash: \
a quote inside singles: can't
a quote inside doubles: "quoted"
Notice that the single-quoted string printed its braces verbatim — {interpolation} is
just text there. Single quotes are perfect when you want a literal that happens to contain
braces, or simply to signal "this is a plain constant, nothing clever happening."
There is no + for strings
Unlike Python or Java, Sushi does not overload + to glue strings together. The one
blessed way to build strings is interpolation, which we meet next. This keeps the
language honest: string building always looks the same.
Interpolation¶
Inside a double-quoted string, anything in {...} is evaluated and spliced in. The
expression can be a variable, arithmetic, or even a method call.
use <collections/strings>
fn main() i32:
let string name = "Arthur"
let i32 answer = 42
# Embed variables directly.
println("Hello, {name}!")
# Any expression works inside the braces.
println("The answer is {answer}, and twice that is {answer * 2}")
# Method calls are expressions too, so they fit inside interpolation.
# Single-quote literals make handy arguments here.
let string text = "hello world"
println("Shouted: {text.upper()}")
println("Padded: {'42'.pad_left(5, '0')}")
# Interpolation is the way to concatenate strings; there is no + operator.
let string first = "Ford"
let string last = "Prefect"
let string full = "{first} {last}"
println(full)
return Result.Ok(0)
Output:
Hello, Arthur!
The answer is 42, and twice that is 84
Shouted: HELLO WORLD
Padded: 00042
Ford Prefect
This is also how you concatenate: "{first} {last}" produces a brand-new string. Note the
neat trick of using single quotes inside the braces — '42'.pad_left(5, '0') — so the
literal argument never collides with the surrounding double quotes.
Inspecting strings¶
The methods that ask questions about a string — its length, whether it contains
something, where a substring lives — live in the <collections/strings> standard library
unit. Import it once at the top of your file with use <collections/strings>.
use <collections/strings>
fn main() i32:
let string entry = "Don't Panic"
# Length in characters vs. size in bytes.
println("len: {entry.len()}")
println("size: {entry.size()}")
# Membership and edge checks return plain bools.
println("contains Panic: {entry.contains('Panic')}")
println("starts with Don: {entry.starts_with('Don')}")
println("ends with ic: {entry.ends_with('ic')}")
# count returns how many non-overlapping times a needle appears.
println("count of n: {entry.count('n')}")
# find returns Maybe<i32>: Some(index) or None.
match entry.find('Panic'):
Maybe.Some(pos) ->
println("Found 'Panic' at {pos}")
Maybe.None() ->
println("'Panic' not found")
return Result.Ok(0)
Output:
len: 11
size: 11
contains Panic: true
starts with Don: true
ends with ic: true
count of n: 2
Found 'Panic' at 6
A few things worth calling out:
.len()counts characters (UTF-8 aware) while.size()counts bytes. For pure ASCII like"Don't Panic"they match; for"café"they won't (len4,size5)..contains(),.starts_with(), and.ends_with()return plainbools, so they slot straight into anif..count(needle)counts non-overlapping occurrences..find(needle)returns aMaybe<i32>—Maybe.Some(index)when found,Maybe.None()when not. That's Sushi refusing to hand you a magic-1; you have to acknowledge the "not found" case. We'll dig intoMaybe<T>properly in Chapter 6.
Transforming strings¶
The other big family of methods returns a reshaped string: trimming whitespace, changing case, padding, and splitting/joining. Because strings are immutable, the original is never touched — you always get a fresh value back.
use <collections/strings>
fn main() i32:
# Trim whitespace, change case.
let string padded = " vogon poetry "
println("trimmed: '{padded.trim()}'")
println("upper: {padded.trim().upper()}")
println("lower: {'LOUD NOISES'.lower()}")
# Pad a number on the left with zeros.
println("ticket: {'7'.pad_left(4, '0')}")
println("label: {'name'.pad_right(8, '.')}")
# split breaks a string into a string[]; join glues one back together.
let string csv = "Earth,Betelgeuse,Magrathea"
let string[] planets = csv.split(',')
println("planets:")
foreach(planet in planets.iter()):
println(" - {planet}")
let string rejoined = ' | '.join(planets)
println("rejoined: {rejoined}")
return Result.Ok(0)
Output:
trimmed: 'vogon poetry'
upper: VOGON POETRY
lower: loud noises
ticket: 0007
label: name....
planets:
- Earth
- Betelgeuse
- Magrathea
rejoined: Earth | Betelgeuse | Magrathea
Highlights:
.trim()strips leading and trailing whitespace;.upper()/.lower()change ASCII case. They chain freely:padded.trim().upper()..pad_left(width, char)and.pad_right(width, char)pad to a width using the given single-character string — handy for lining up columns or zero-padding numbers..split(delimiter)returns astring[](a dynamic array), which you iterate with.iter()in aforeach. Arrays get their own chapter soon..join(parts)is the inverse: it's called on the separator, so' | '.join(planets)reads almost like English.
What's in <collections/strings>?
The bare essentials — interpolation, escapes, the literal syntaxes — are built into the
language and need no import. The richer methods (contains, find, count, upper,
lower, trim, pad_left, pad_right, split, join, replace, reverse,
repeat, slicing helpers, and more) come from use <collections/strings>. If the
compiler complains that a method needs a stdlib unit, that import is almost always the
fix.
What you learned¶
- Sushi strings are immutable and UTF-8; double quotes interpolate, single quotes don't.
- Interpolation
"{expr}"is the one way to build and concatenate strings — there's no string+. - Escape sequences (
\n,\t,\\,\',\") work in both quote styles. use <collections/strings>unlocks the method library:len/size,contains,starts_with/ends_with,find(returnsMaybe<i32>),count,trim,upper/lower,pad_left/pad_right,split, andjoin.
We kept bumping into Maybe and Result. It's time to meet them head-on. On to
Error Handling.