Work with strings 🔥
Advent of Code inputs arrive as text, but the answers are hiding inside: numbers, labels, separators, commands, coordinates, and clues.
Before you can solve the puzzle, you have to take the input apart.
This page picks up the sensor log from Work with files and walks through the string operations you’ll reach for again and again: trim it, search it, slice it, split it, and reshape it. The first line of that log is deliberately messy, so you have real whitespace and casing problems to clean up as you go.
It’s plain ASCII
Advent of Code inputs are ASCII. That means one byte per character, simple indexing, and no grapheme-cluster surprises.
For puzzle input, reach for String and work by byte. It’s fast, direct,
and a good fit for extracting fields, checking markers, and splitting lines
into useful pieces.
Build a string
Create string_tour.mojo, and grab the first entry from the same sensor log
you read in Work with files:
var log = Path("src/downloads/temp_log.txt")
var string: String = String(log.read_text().splitlines()[0])
print(t"string: '{string}'")
# single quotes make surrounding whitespace easy to spot
Note that splitlines()[0] indexes the zeroth element of the text split by
lines, so var string gets the first line of the text file. The logging script
isn’t perfectly tidy. The leading and trailing spaces on that first line will
need to be cleaned up.
The first line ends with two spaces on purpose. Some editors and formatters
strip trailing whitespace when you save, which changes the byte counts in this
chapter. If your copy says 30 bytes where this page says 32, add the two spaces
back to the end of line 1 of temp_log.txt.
Takeaways
- Reading the log with
read_text()can fail, somain()needsraises.
Measure the input
Before you start cutting up the line, count your characters. You can use a pre-computed length for any work that involves the end of the string, like reversing and suffixes.
For ASCII input only, byte_length() gives you a string length:
var length = string.byte_length()
T-strings (prefixed with the letter t) let you interpolate values
into a string template and print them:
print(t"{string} length: {length}") # 32
Takeaways
- T-Strings are templates, not strings.
print()accepts them directly.- Convert a T-String to
Stringwhen you need to store or manipulate the resulting text as a string, or return a string from a function or method.
String iteration
Sometimes a puzzle makes you inspect input one character at a time: looking for markers, counting symbols, or building values as you go.
Iterate over codepoint_slices() to walk through the text. Each slice is a
view into the original string that references one Unicode codepoint. With
ASCII input, each codepoint is one character.
This example uses a Mojo list comprehension to collect the individual characters and convert each slice to a single-quoted string:
print([String(t"'{slice}'") for slice in string.codepoint_slices()])
# [' ', ' ', 'D', 'a', 'y', ' ', '1', ':', ' ', '-', '2', '0', '.', '5',
# 'C', ',', ' ', 'P', 'a', 'r', 't', 'l', 'y', ' ', 'C', 'l', 'o',
# 'u', 'd', 'y', ' ', ' ']
Takeaways
- String iteration walks your input character-by-character. This lets you locate markers and symbols in your puzzles, plus build values.
- When working with C-like language interop, string bytes map to
char*. To extract byte data, useunsafe_ptr()(a pointer to the underlying memory), notbytes()(an iterator). - Call
string.as_c_string_span()to return a null-terminated string. No change if the string is already null-terminated.
Join slices from lists
Taking input apart is only half the job. Sometimes you keep the pieces you want and need to put them back together.
Use join() to combine values into a string. Call the method on the
separator you want to place between them.
To rebuild the original line, use an empty separator:
var joined = "".join([slice for slice in string.codepoint_slices()])
print(t"'{joined}'") # ' Day 1: -20.5C, Partly Cloudy '
To rebuild the original line without spaces, filter them out:
joined = "".join(
[slice for slice in string.codepoint_slices() if " " not in slice]
)
print(t"'{joined}'") # 'Day1:-20.5C,PartlyCloudy'
Takeaways
join()supports any list ofWritablevalues.- Use an empty separator to concatenate values:
"".join(parts). - A non-empty separator appears only between elements, not after the last element.
For example:
var hello: String = "Hello"
joined = ", ".join([slice for slice in hello.codepoint_slices()])
print(t"'{joined}'") # 'H, e, l, l, o'
Clean up the edges
Puzzle input often comes with whitespace you don’t want: spaces around
fields, indentation, or trailing newlines. Use strip() to remove
whitespace from both ends.
Clean up your reading line:
var cleaned = string.strip()
print(t"cleaned: '{cleaned}', bytes: {cleaned.byte_length()}")
The string length drops from 32 to 28 as the white space is trimmed. From
here on, the examples work on cleaned instead of the raw string.
Takeaways
strip()returns aStringSpanwithout leading and trailing whitespace.- Use
lstrip()to clean the left edge only, andrstrip()for the right.
Try this
You can tell strip() exactly which characters to remove:
# Chiller flags critical readings with asterisks
var flagged = "**" + cleaned + "**"
print(t"'{flagged.strip("*")}'") # 'Day 1: -20.5C, Partly Cloudy'
Reverse a string
Puzzles love making you read things backward: mirrored patterns, reversed paths, and sequences that need to match in either direction.
For ASCII input, you can reverse the bytes by walking backward from the end of the string:
var s: String = ""
# construct and reverse the non-inclusive range
for index in reversed(range(cleaned.byte_length())):
s = s + String(cleaned[byte=index])
print(t"Reversed bytes: '{s}'") # 'yduolC yltraP ,C5.02- :1 yaD'
Strings also provide a reversed slice iterator:
# use the reversed iterator
s = ""
for slice in cleaned.codepoint_slices_reversed():
s += String(slice)
print(t"'{s}'") # 'yduolC yltraP ,C5.02- :1 yaD'
Takeaways
- For ASCII sources, reversing bytes and reversing codepoints produces the same result.
reversed(range(cleaned.byte_length()))walks the byte indices from the end to the beginning.codepoint_slices_reversed()gives you an iterator that walks Unicode codepoints in reverse without callingreversed().
Slice out what you need
Puzzle inputs often pack several pieces of information into one line. When you know where the data you want appears, indexing and slicing let you reach directly into the text.
Mojo strings support three indexing schemes. You must state the one you’re
using. Calling string[0] is an error:
byte=indexes the underlying bytes. Pass an integer or contiguous slice. Access is O(1).codepoint=indexes Unicode codepoints. Pass an integer or contiguous slice. Access is O(N).grapheme=indexes a single Unicode grapheme cluster. Access is O(N). Grapheme indexing doesn’t support slices.
For ASCII puzzle input, use byte=.
Slice the line
Slices let you grab a prefix, suffix, or section from the middle:
n:mselects fromnthroughm - 1:[n, m).n:selects the suffix starting atn:[n, length).:mselects the prefix ending beforem:[0, m).
Try all three on the puzzle input:
# O(1) byte slicing to a view
print(t"byte prefix: '{cleaned[byte=:5]}'") # 'Day 1'
print(
t"byte suffix: '{cleaned[byte=cleaned.byte_length() - 5:]}'"
) # 'loudy'
print(t"byte substring: '{cleaned[byte=2:10]}'") # 'y 1: -20'
Takeaways
- For ASCII input,
byte=andcodepoint=give you the same results, but byte indexing is faster. - Indexed content returns a view into the string’s data, not a copy.
Search for clues
Before you parse a line, you often need to know what it contains. Does it have the marker you need? Where does a field start? Does the line have the right prefix or suffix?
Three tools cover these common searches: in, find(), and
startswith() / endswith().
Try them on your input:
# Contains
print(t"contains 'Day': {'Day' in cleaned}") # True
# Position
print(t"position of ':': {cleaned.find(':')}") # 5
print(t"position of '!': {cleaned.find('!')}") # -1, not found
# Start and end
print(t"starts with 'Day': {cleaned.startswith('Day')}") # True
print(t"ends with 'Cloudy': {cleaned.endswith('Cloudy')}") # True
Run it. You should see:
contains 'Day': True
position of ':': 5
position of '!': -1
starts with 'Day': True
ends with 'Cloudy': True
Takeaways
- Use
sub in swhen you need a yes-or-no containment check. s.find(sub)returns the byte position of the first match, or-1when the substring isn’t present.s.startswith(prefix)ands.endswith(suffix)test the ends directly. They’re useful for filtering input lines by markers or tags.
Try this
There’s a trap hiding in find(). A missing value returns -1. In Mojo,
-1 is truthy:
if cleaned.find("!"):
print("-1 is truthy") # This prints
else:
print("You'd expect to be here, but you're not")
That’s why you shouldn’t use find() as a containment test. Use in:
if "!" in cleaned:
print("Found")
else:
print("Not found") # This prints
"x" in s returns a Bool. Reach for find() when you need the
position. Reach for in when you only need to know whether it’s there.
Test for empty strings
You just saw that integers have truthiness. Strings do too. An empty string is falsy and a non-empty string is truthy:
if "":
print("Won't be printed")
elif "🔥":
print("This is printed")
Takeaways
Use string truthiness when you care whether text is empty:
if cleaned:
# There's something to process.
print(t"Processing: '{cleaned}'")
Use in when you care whether specific text appears.
Replace substrings
Sometimes puzzle input contains text you want to normalize before you
process it. Use replace(old, new) to replace every occurrence of one
substring with another.
Standardize the weather description:
var standardized = cleaned.replace("Partly Cloudy", "Cloudy")
var no_units = standardized.replace("C", "")
print(standardized)
print(no_units) # loudy! Maybe not a great idea
Run it. You should see Day 1: -20.5C, Cloudy and Day 1: -20.5, loudy.
Oops. You removed the C from Cloudy, too.
Takeaways
replace()returns a newString. The original is unchanged.- The replacement can have a different length from the text it replaces.
replace()replaces every match, not just the one you had in mind.
Split the line into fields
Your cleaned line still contains two different pieces of information: the
reading and the weather description. When input has a consistent separator,
use split(sep) to break it into fields.
Split on the comma and space:
var parts = cleaned.split(", ")
print(t"split on ', ': {parts}") # [Day 1: -20.5C, Partly Cloudy]
print(t"count: {len(parts)}") # 2
Now you can work with “Day 1: -20.5C” and “Partly Cloudy” separately.
Takeaways
split(sep)returns aList[StringSpan]. Each slice is a view into the original string, not a copy.split()with no argument splits on runs of whitespace and drops empty segments. Reach for it when fields are separated by inconsistent spacing.
Normalize for matching
Sometimes you care about the text but not its capitalization. Normalize the case before you compare or search.
You already split the weather description into parts[1]. Try both
directions:
print(parts[1].lower()) # partly cloudy
print(parts[1].upper()) # PARTLY CLOUDY
Now you can make a case-insensitive containment check by normalizing before you search:
print(t"'cloudy' matches: {'cloudy' in cleaned.lower()}") # True
Takeaways
lower()andupper()return new strings. They don’t change the original.- They operate on ASCII letters only.
- For Advent’s ASCII input, normalizing case gives you a simple way to compare text without worrying about capitalization.
Final code
Your complete string_tour.mojo:
from std.pathlib import Path
def main() raises:
var log = Path("src/downloads/temp_log.txt")
var string: String = String(log.read_text().splitlines()[0])
print(t"string: '{string}'")
# single quotes make surrounding whitespace easy to spot
var length = string.byte_length()
print(t"{string} length: {length}") # 32
print([String(t"'{slice}'") for slice in string.codepoint_slices()])
# [' ', ' ', 'D', 'a', 'y', ' ', '1', ':', ' ', '-', '2', '0', '.', '5',
# 'C', ',', ' ', 'P', 'a', 'r', 't', 'l', 'y', ' ', 'C', 'l', 'o',
# 'u', 'd', 'y', ' ', ' ']
var joined = "".join([slice for slice in string.codepoint_slices()])
print(t"'{joined}'") # ' Day 1: -20.5C, Partly Cloudy '
joined = "".join(
[slice for slice in string.codepoint_slices() if " " not in slice]
)
print(t"'{joined}'") # 'Day1:-20.5C,PartlyCloudy'
var hello: String = "Hello"
joined = ", ".join([slice for slice in hello.codepoint_slices()])
print(t"'{joined}'") # 'H, e, l, l, o'
var cleaned = string.strip()
print(t"cleaned: '{cleaned}', bytes: {cleaned.byte_length()}")
# Chiller flags critical readings with asterisks
var flagged = "**" + cleaned + "**"
print(t"'{flagged.strip("*")}'") # 'Day 1: -20.5C, Partly Cloudy'
var s: String = ""
# construct and reverse the non-inclusive range
for index in reversed(range(cleaned.byte_length())):
s = s + String(cleaned[byte=index])
print(t"Reversed bytes: '{s}'") # 'yduolC yltraP ,C5.02- :1 yaD'
# use the reversed iterator
s = ""
for slice in cleaned.codepoint_slices_reversed():
s += String(slice)
print(t"'{s}'") # 'yduolC yltraP ,C5.02- :1 yaD'
# O(1) byte slicing to a view
print(t"byte prefix: '{cleaned[byte=:5]}'") # 'Day 1'
print(
t"byte suffix: '{cleaned[byte=cleaned.byte_length() - 5:]}'"
) # 'loudy'
print(t"byte substring: '{cleaned[byte=2:10]}'") # 'y 1: -20'
# Contains
print(t"contains 'Day': {'Day' in cleaned}") # True
# Position
print(t"position of ':': {cleaned.find(':')}") # 5
print(t"position of '!': {cleaned.find('!')}") # -1, not found
# Start and end
print(t"starts with 'Day': {cleaned.startswith('Day')}") # True
print(t"ends with 'Cloudy': {cleaned.endswith('Cloudy')}") # True
if cleaned.find("!"):
print("-1 is truthy") # This prints
else:
print("You'd expect to be here, but you're not")
if "!" in cleaned:
print("Found")
else:
print("Not found") # This prints
if "":
print("Won't be printed")
elif "🔥":
print("This is printed")
if cleaned:
# There's something to process.
print(t"Processing: '{cleaned}'")
var standardized = cleaned.replace("Partly Cloudy", "Cloudy")
var no_units = standardized.replace("C", "")
print(standardized)
print(no_units) # loudy! Maybe not a great idea
var parts = cleaned.split(", ")
print(t"split on ', ': {parts}") # [Day 1: -20.5C, Partly Cloudy]
print(t"count: {len(parts)}") # 2
print(parts[1].lower()) # partly cloudy
print(parts[1].upper()) # PARTLY CLOUDY
print(t"'cloudy' matches: {'cloudy' in cleaned.lower()}") # True
Topics covered
String lengths, bytes vs codepoints, indexing, slicing, content operators, trimming, replacement, splits, casing, and join.
Also worth knowing
Prefixes and suffixes:
removeprefix(prefix) and removesuffix(suffix) are the surgical
versions of replace. They strip the substring only if it appears at
the relevant end. Safer than replace when the same substring might
appear elsewhere in the string.
Iterating over characters:
for ch in s.codepoints(): walks each Unicode codepoint as a
Codepoint. for slice in s.codepoint_slices(): gives you each
codepoint as a StringSpan you can compare to a literal. Cast
the slices to String to use them with string construction.
Counting:
s.count(sub) returns how many non-overlapping copies of sub appear
in s. Pair it with find and replace for full
search-and-substitute work.