Working with text
Build, cut, search, clean and format text: screen messages, item names, commands typed by the player, settings files.
Text is everywhere in a computer program: the messages on the screen, the item names a vault returns ("minecraft:iron_ingot"), the line a player types at the keyboard, a settings file on the floppy disk, a message from another computer. In Brass, a piece of text is a string, and the string library works on it.
This guide is the practical side: how to do the everyday jobs. Every function it uses is described in detail in string.
local item = "minecraft:iron_ingot"
local count = 1234
print("Vault: " .. count .. " x " .. item)
print(string.format("%-10s %6d", "iron", count))Vault: 1234 x minecraft:iron_ingot iron 1234
A string never changes. upper, sub, trim and the others give back a new string and leave the original as it was: name:upper() alone does nothing useful, name = name:upper() keeps the result.
Writing text in a program
A string goes between double quotes "..." or single quotes '...'. Both are the same; pick the one that saves you from escaping:
print("Station \"North\"")
print('Say "hi" to the Mechanical Arm')
print("It's 12 o'clock")Station "North" Say "hi" to the Mechanical Arm It's 12 o'clock
A backslash starts an escape, a character you cannot type directly:
| escape | gives |
|---|---|
\n | a new line |
\t | a tab (the screen moves to the next column that is a multiple of 4) |
\\ | one backslash |
\" and \' | a quote |
\xNN | the character with the hexadecimal code NN (\x41 is A) |
\r, \0 | carriage return, the zero character |
Any other letter after a backslash is a mistake that stops the program from compiling: invalid escape sequence '\q'.
print("Item\tCount")
print("Iron\t1250")
print("Line one\nLine two")Item Count Iron 1250 Line one Line two
Text on several lines
Between [[ and ]], a string can span several lines and is kept exactly as written: no escapes are read inside, and a line break right after [[ is skipped. It is perfect for a help screen or a default settings file:
local help = [[
Commands:
open opens the gate
close closes the gate
status shows the train schedule]]
print(help)Commands: open opens the gate close closes the gate status shows the train schedule
To work line by line on such a text, cut it at the line breaks with text:split("\n") (see splitting text).
Joining text
The .. operator glues two strings together. Numbers are turned into text on the way, so you can join them directly:
local name, stock = "Andesite Alloy", 64
print(name .. ": " .. stock .. " in stock")
print("Speed " .. 128 .. " RPM")Andesite Alloy: 64 in stock Speed 128 RPM
Other values are not converted: nil, true, false and tables stop the program. Wrap them in tostring first.
local powered = true
print("Lamp on: " .. powered)snippet:2: attempt to concatenate a boolean value
local powered = true
print("Lamp on: " .. tostring(powered))Lamp on: true
Building text in a loop
Each .. makes a new string. Gluing pieces one by one in a long loop works, but it creates many strings along the way (they take memory and time). The better tool is to collect the pieces in a table and join them once with table.concat, with a separator if you want one:
local ores = {"iron", "gold", "copper", "zinc"}
print(table.concat(ores, ", "))
local cells = {}
for floor = 1, 5 do
table.insert(cells, "F" .. floor)
end
print(table.concat(cells, " | "))iron, gold, copper, zinc F1 | F2 | F3 | F4 | F5
Calling string functions
Every function of the string library can be called in two ways: string.upper(name), or with a colon on the string itself, name:upper(). The second form is shorter and reads left to right, which helps when you chain calls:
local name = " brass casing "
print(string.upper(name))
print(name:trim():upper())
print(("zinc"):rep(3, "-"))BRASS CASING BRASS CASING zinc-zinc-zinc
A string written directly in the code needs parentheses before the colon: ("zinc"):rep(3). Numbers have no methods: (42):upper() stops with attempt to index a number value. Convert first, tostring(42).
Length and positions
#text (or text:len()) is the number of characters. Positions start at 1, and a negative position counts from the end: -1 is the last character, -2 the one before.
local word = "Create"
print(#word, word:len())
print(word:sub(1, 1), word:sub(-1))
print(#"café")6 6 C e 4
Brass counts characters, not bytes: an accented letter like é counts for one. (The size of a file, given by fs.size, is in bytes, where é takes two.)
Cutting text: sub
text:sub(i, j) gives the characters from position i to position j, both included. Without j, it goes to the end. Positions out of range are not an error: you just get less text, or an empty string.
local id = "minecraft:iron_ingot"
print(id:sub(1, 9))
print(id:sub(11))
print(id:sub(-5))
print(id:sub(11, 14))
print("[" .. id:sub(30, 40) .. "]")minecraft iron_ingot ingot iron []
To go through a text one character at a time, take each one with sub(i, i):
local code = "R2D"
for i = 1, #code do
print(i, code:sub(i, i))
end1 R 2 2 3 D
Searching
find: where is it?
text:find(what) gives the position where what starts, or nil when it is not there. The search is plain: every character is looked for as written (dots, percent signs and brackets have no special meaning, unlike Lua's patterns). A third argument starts the search further on.
local id = "create:brass_ingot"
local colon = id:find(":")
print(colon)
print(id:sub(1, colon - 1), id:sub(colon + 1))
print(id:find("ingot"))
print(id:find("iron"))7 create brass_ingot 14 nil
find gives only the start. The end of the match is start + #what - 1.
Always test for nil before using the result: id:sub(1, id:find(":") - 1) stops with attempt to perform arithmetic on a nil value when the text has no colon.
To find every occurrence, start each search just after the previous one:
local log = "iron,gold,iron,coal,iron"
local count, from = 0, 1
while true do
local at = log:find("iron", from)
if at == nil then
break
end
count = count + 1
from = at + #"iron"
end
print(count .. " times")3 times
starts and ends
text:starts(prefix) and text:ends(suffix) answer true or false. They are the clearest way to sort names: log files, items of a mod, commands.
local files = {"stock.log", "startup", "stock_old.log", "lib"}
for _, f in ipairs(files) do
if f:starts("stock") and f:ends(".log") then
print(f)
end
end
print(("create:cogwheel"):starts("create:"))stock.log stock_old.log true
Splitting text
text:split(separator) cuts a text at each separator and returns a list (a table) of the pieces. It is the key to reading lines of a file, values separated by commas, or words.
local line = "iron,gold,,copper"
local parts = line:split(",")
print(#parts .. " parts")
for i, p in ipairs(parts) do
print(i, "[" .. p .. "]")
end4 parts 1 [iron] 2 [gold] 3 [] 4 [copper]
Two separators in a row give an empty piece, as the third one above. Without a separator, split cuts at each space, so two spaces also give an empty word; with "" it cuts into single characters:
local words = ("open north gate"):split()
print(#words .. " words")
local letters = ("RPM"):split("")
print(letters[1], letters[2], letters[3])4 words R P M
When you want real words, skip the empty pieces (see reading a command below).
Cleaning what the player types
Text typed at the keyboard (with read) or pasted is rarely clean: spaces at the ends, capital letters where you did not expect them. Two calls fix most of it:
text:trim()removes the spaces, tabs and line breaks at both ends;text:lower()(orupper) puts every letter in the same case, so thatOpen,OPENandopencompare equal.
local typed = " Open Gate "
local clean = typed:trim():lower()
print("[" .. clean .. "]")
print(clean == "open gate")[open gate] true
Numbers and text
read and fs.read always give text, even when it looks like a number. Brass does not convert text to a number by itself in a calculation:
local typed = "12"
print(typed * 2)snippet:2: attempt to perform arithmetic on a strin g value
tonumber(text) does the conversion. It accepts spaces around the number, decimals, exponents (1e3) and hexadecimal (0xFF), and returns nil when the text is not a number. Give a base as second argument for other systems (2 to 36).
print(tonumber("42") + 1)
print(tonumber(" 3.5 "))
print(tonumber("12 items"))
print(tonumber("ff", 16), tonumber("1010", 2))43 3.5 nil 255 10
Always check for nil, the player may type anything:
local typed = "twelve"
local n = tonumber(typed)
if n == nil then
print("'" .. typed .. "' is not a number")
else
print(n * 2)
end'twelve' is not a number
The other way, tostring(value) turns anything into text, and .. does it for numbers. Whole numbers show without a decimal point; other numbers show up to 14 significant digits, and very large or very small ones use an exponent:
print(10 / 2, 10 / 4, 1 / 3)
print(0.1 + 0.2, 2 ^ 60)5 2.5 0.33333333333333 0.3 1.1529215046068e+18
Formatting with string.format
string.format(pattern, values...) builds a text from a model: each % code in the model is replaced by the next value, written the way the code says. It is the tool for fixed decimals, aligned columns and leading zeros.
print(string.format("Speed: %d RPM", 128))
print(string.format("Stress: %.1f%%", 72.456))
print(string.format("Time: %02d:%02d", 7, 5))
print(("%s holds %d items"):format("Vault", 1500))Speed: 128 RPM Stress: 72.5% Time: 07:05 Vault holds 1500 items
| code | writes | example | result |
|---|---|---|---|
%d | a whole number | format("%d", 42) | 42 |
%5d | right-aligned in 5 columns | format("%5d", 42) | 42 |
%-5d | left-aligned in 5 columns | format("%-5d", 42) | 42 |
%05d | padded with zeros | format("%05d", 42) | 00042 |
%+d | always with a sign | format("%+d", 42) | +42 |
%.1f | a number with 1 decimal (rounded) | format("%.1f", 3.14159) | 3.1 |
%8.2f | 8 columns, 2 decimals | format("%8.2f", 3.14159) | 3.14 |
%s | any value as text (like tostring) | format("%s", true) | true |
%-10s | text padded on the right to 10 columns | format("%-10s", "iron") | iron |
%.3s | the first 3 characters | format("%.3s", "copper") | cop |
%x, %X | hexadecimal | format("%X", 255) | FF |
%% | a percent sign | format("%d%%", 50) | 50% |
%i is the same as %d. Widths and decimals go up to 99. There is no %q, %e, %g, %c or %o: they stop the program with invalid conversion '%q' to 'format'.
Two mistakes to know:
%dwants a whole number.string.format("%d", 2.5)stops withbad argument #2 to 'format' (number has no integer representation): round first withmath.floorormath.round, or use%.0f.- Each
%code needs a value. A missing one stops withbad argument #2 to 'format' (no value).
Aligned columns
Widths line up a table on the screen, whatever the length of each name or number:
local stock = {
{name = "Iron Ingot", count = 1250, max = 2000},
{name = "Gold Ingot", count = 87, max = 500},
{name = "Brass Ingot", count = 640, max = 640},
}
print(string.format("%-14s %6s %6s", "Item", "Count", "Full"))
for _, s in ipairs(stock) do
print(string.format("%-14s %6d %5.1f%%", s.name, s.count, s.count / s.max * 100))
endItem Count Full Iron Ingot 1250 62.5% Gold Ingot 87 17.4% Brass Ingot 640 100.0%
Centering and lines
string.rep(text, n) repeats a text: a line of = across the screen, or the spaces that center a title. term.get_size() gives the width of the screen (51 columns on a Personal Computer, 40 on the Tube Computer and the Microcontroller, 64 on the Modern Computer).
local function center(text, width)
local left = math.floor((width - #text) / 2)
return string.rep(" ", left) .. text
end
local width = term.get_size().w
print(center("BRASS JUNCTION", width))
print(string.rep("=", width))BRASS JUNCTION ===================================================
Characters and their codes
Each character has a number, its code: "A" is 65, "a" is 97, "0" is 48. text:byte(i) gives the code of the character at position i (the first one by default), and string.char(...) builds a text from codes. Letters and digits follow each other: "0" to "9" are 48 to 57, "A" to "Z" 65 to 90, "a" to "z" 97 to 122.
print(("A"):byte(), ("Create"):byte(2))
print(string.char(72, 105, 33))
local function is_digit(c)
local code = c:byte()
return code ~= nil and code >= 48 and code <= 57
end
print(is_digit("7"), is_digit("x"))
local function capitalize(word)
return word:sub(1, 1):upper() .. word:sub(2)
end
print(capitalize("andesite"))65 114 Hi! true false Andesite
Strings can also be compared with < and >, character code by character code: "apple" < "banana" is true. That is how table.sort puts a list of names in order. Capital letters come before small ones ("Zinc" < "iron"), and accented letters after all the others ("étain" > "zinc"): compare lower() versions for a more natural order.
Recipes
Reading a command typed by the player
A control program often waits for a command: open north, speed 96. The steps are always the same: clean the line, cut it into words (skipping the empty ones that extra spaces leave), then look at the first word.
local function words_of(line)
local words = {}
for _, w in ipairs(line:trim():split(" ")) do
if w ~= "" then
table.insert(words, w)
end
end
return words
end
local function run(line)
local words = words_of(line)
local command = (words[1] or ""):lower()
if command == "open" then
print("opening " .. (words[2] or "every gate"))
elseif command == "speed" then
local rpm = tonumber(words[2])
if rpm == nil then
print("usage: speed <rpm>")
else
print("speed set to " .. rpm .. " RPM")
end
elseif command ~= "" then
print("unknown command: " .. command)
end
end
-- a few lines the player could type
for _, line in ipairs({"open north", " SPEED 96 ", "speed fast", "dance"}) do
print("> " .. line)
run(line)
end> open north opening north > SPEED 96 speed set to 96 RPM > speed fast usage: speed <rpm> > dance unknown command: dance
In the real program, the lines come from the keyboard, with read():
while true do
write("> ")
run(read())
endA settings file
Let players change a program without touching its code: put the settings in a text file with one key = value per line, and read it at start. This reader skips blank lines and lines starting with #, turns numbers into numbers and true/false into booleans:
-- the file, as a player would write it with "edit settings"
fs.write("settings", [[
# Iron farm controller
station = Iron Mine
threshold = 1500
alarm_side = top
debug = false
]])
local function load_settings(path)
local settings = {}
local text = fs.read(path)
if text == nil then
return settings -- no file: an empty table, defaults apply
end
for n, line in ipairs(text:split("\n")) do
line = line:trim()
if line ~= "" and not line:starts("#") then
local eq = line:find("=")
if eq == nil then
error(path .. " line " .. n .. ": '=' expected")
end
local key = line:sub(1, eq - 1):trim()
local value = line:sub(eq + 1):trim()
if tonumber(value) ~= nil then
value = tonumber(value)
elseif value == "true" or value == "false" then
value = value == "true"
end
settings[key] = value
end
end
return settings
end
local s = load_settings("settings")
print(s.station, s.threshold + 1, s.alarm_side, s.debug)Iron Mine 1501 top false
Default values go with or: local side = s.alarm_side or "back". (Careful with booleans: s.debug or true is always true.) Another way to store settings is a Brass file that returns a table, loaded with import: see Programs in several files.
Wrapping a long text to the screen
print goes to the next line when a line is full, even in the middle of a word:
print("The northern line is closed until the Mechanical Drills finish the new tunnel.")The northern line is closed until the Mechanical Dr ills finish the new tunnel.
To cut between words, build the lines yourself. This function also cuts a word that is longer than a whole line:
local function wrap(text, width)
local lines = {}
local line = ""
for _, word in ipairs(text:split(" ")) do
while #word > width do
if line ~= "" then
table.insert(lines, line)
line = ""
end
table.insert(lines, word:sub(1, width))
word = word:sub(width + 1)
end
if word == "" then
-- nothing left of this word
elseif line == "" then
line = word
elseif #line + 1 + #word <= width then
line = line .. " " .. word
else
table.insert(lines, line)
line = word
end
end
if line ~= "" then
table.insert(lines, line)
end
return lines
end
local news = "The northern line is closed until the Mechanical Drills finish the new tunnel. Trains for the Iron Mine leave from platform 2."
for _, l in ipairs(wrap(news, term.get_size().w)) do
print(l)
endThe northern line is closed until the Mechanical Drills finish the new tunnel. Trains for the Iron Mine leave from platform 2.
The function returns a list of lines rather than printing them, so you can also count them (to center a text vertically) or draw them at a chosen place with term.set_cursor and term.write.
Doing without patterns
Lua has patterns, a small search language (%d+, %s*, (.-)) used by string.match, string.gmatch and string.gsub. Brass has none of them: find searches the text exactly as written, and match, gmatch and gsub do not exist. The common jobs are short to write with split, find, sub and trim:
| in Lua | in Brass |
|---|---|
s:gsub("_", " ") | table.concat(s:split("_"), " ") |
s:match("^%s*(.-)%s*$") | s:trim() |
for w in s:gmatch("%S+") do | for _, w in ipairs(s:split(" ")) do and skip w == "" |
s:match("^(%w+)=(.*)$") | local eq = s:find("="), then s:sub(1, eq - 1) and s:sub(eq + 1) |
s:find("^prefix") | s:starts("prefix") |
s:find("%.log$") | s:ends(".log") |
tonumber(s:match("%d+")) | a loop over the characters (below) |
Replace a text by another: split at the old text, join with the new one.
local function replace(text, old, new)
return table.concat(text:split(old), new)
end
print(replace("iron_ingot", "_", " "))
print(replace("north--south--east", "--", " > "))iron ingot north > south > east
Squeeze repeated spaces into one:
local function squeeze(text)
local words = {}
for _, w in ipairs(text:split(" ")) do
if w ~= "" then
table.insert(words, w)
end
end
return table.concat(words, " ")
end
print("[" .. squeeze(" too many spaces ") .. "]")[too many spaces]
Take the first number out of a text, as a sensor or another mod might write it:
local function first_number(text)
local digits = ""
for i = 1, #text do
local c = text:sub(i, i)
if (c >= "0" and c <= "9") or (c == "." and digits ~= "") then
digits = digits .. c
elseif digits ~= "" then
break
end
end
return tonumber(digits)
end
print(first_number("Speed: 128 RPM"))
print(first_number("Stress 72.5% used"))
print(first_number("no digits here"))128 72.5 nil
Count the occurrences of a text: the number of pieces after a split, minus one.
local route = "depot>mine>depot>farm>depot"
print(#route:split("depot") - 1)3
Strings cost memory: about one cell per 8 characters (see Speed, memory and limits), and a string holds 65536 characters at most (string too long). find, split, print and fs.write also cost a few extra instructions on very long texts. For everyday messages and settings files, none of this matters.
See also
string: every string function, with its arguments and edge cases.Tables: the lists thatsplitreturns andtable.concatjoins.Errors and debugging: what the error messages above mean, and how to catch them.fs: reading and writing text files.