Create: Computing AgesBrass Docs
The Brass language

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.

Brass
local item = "minecraft:iron_ingot"
local count = 1234
print("Vault: " .. count .. " x " .. item)
print(string.format("%-10s %6d", "iron", count))
Screen
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:

Brass
print("Station \"North\"")
print('Say "hi" to the Mechanical Arm')
print("It's 12 o'clock")
Screen
Station "North"
Say "hi" to the Mechanical Arm
It's 12 o'clock

A backslash starts an escape, a character you cannot type directly:

escapegives
\na new line
\ta tab (the screen moves to the next column that is a multiple of 4)
\\one backslash
\" and \'a quote
\xNNthe character with the hexadecimal code NN (\x41 is A)
\r, \0carriage return, the zero character

Any other letter after a backslash is a mistake that stops the program from compiling: invalid escape sequence '\q'.

Brass
print("Item\tCount")
print("Iron\t1250")
print("Line one\nLine two")
Screen
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:

Brass
local help = [[
Commands:
  open    opens the gate
  close   closes the gate
  status  shows the train schedule]]
print(help)
Screen
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:

Brass
local name, stock = "Andesite Alloy", 64
print(name .. ": " .. stock .. " in stock")
print("Speed " .. 128 .. " RPM")
Screen
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.

Brass
local powered = true
print("Lamp on: " .. powered)
Screen
snippet:2: attempt to concatenate a boolean value
Brass
local powered = true
print("Lamp on: " .. tostring(powered))
Screen
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:

Brass
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, " | "))
Screen
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:

Brass
local name = "  brass casing "
print(string.upper(name))
print(name:trim():upper())
print(("zinc"):rep(3, "-"))
Screen
  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.

Brass
local word = "Create"
print(#word, word:len())
print(word:sub(1, 1), word:sub(-1))
print(#"café")
Screen
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.

Brass
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) .. "]")
Screen
minecraft
iron_ingot
ingot
iron
[]

To go through a text one character at a time, take each one with sub(i, i):

Brass
local code = "R2D"
for i = 1, #code do
  print(i, code:sub(i, i))
end
Screen
1   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.

Brass
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"))
Screen
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:

Brass
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")
Screen
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.

Brass
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:"))
Screen
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.

Brass
local line = "iron,gold,,copper"
local parts = line:split(",")
print(#parts .. " parts")
for i, p in ipairs(parts) do
  print(i, "[" .. p .. "]")
end
Screen
4 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:

Brass
local words = ("open  north gate"):split()
print(#words .. " words")
local letters = ("RPM"):split("")
print(letters[1], letters[2], letters[3])
Screen
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() (or upper) puts every letter in the same case, so that Open, OPEN and open compare equal.
Brass
local typed = "   Open Gate  "
local clean = typed:trim():lower()
print("[" .. clean .. "]")
print(clean == "open gate")
Screen
[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:

Brass
local typed = "12"
print(typed * 2)
Screen
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).

Brass
print(tonumber("42") + 1)
print(tonumber(" 3.5 "))
print(tonumber("12 items"))
print(tonumber("ff", 16), tonumber("1010", 2))
Screen
43
3.5
nil
255 10

Always check for nil, the player may type anything:

Brass
local typed = "twelve"
local n = tonumber(typed)
if n == nil then
  print("'" .. typed .. "' is not a number")
else
  print(n * 2)
end
Screen
'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:

Brass
print(10 / 2, 10 / 4, 1 / 3)
print(0.1 + 0.2, 2 ^ 60)
Screen
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.

Brass
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))
Screen
Speed: 128 RPM
Stress: 72.5%
Time: 07:05
Vault holds 1500 items
codewritesexampleresult
%da whole numberformat("%d", 42)42
%5dright-aligned in 5 columnsformat("%5d", 42)42
%-5dleft-aligned in 5 columnsformat("%-5d", 42)42
%05dpadded with zerosformat("%05d", 42)00042
%+dalways with a signformat("%+d", 42)+42
%.1fa number with 1 decimal (rounded)format("%.1f", 3.14159)3.1
%8.2f8 columns, 2 decimalsformat("%8.2f", 3.14159)3.14
%sany value as text (like tostring)format("%s", true)true
%-10stext padded on the right to 10 columnsformat("%-10s", "iron")iron
%.3sthe first 3 charactersformat("%.3s", "copper")cop
%x, %Xhexadecimalformat("%X", 255)FF
%%a percent signformat("%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:

  • %d wants a whole number. string.format("%d", 2.5) stops with bad argument #2 to 'format' (number has no integer representation): round first with math.floor or math.round, or use %.0f.
  • Each % code needs a value. A missing one stops with bad argument #2 to 'format' (no value).

Aligned columns

Widths line up a table on the screen, whatever the length of each name or number:

Brass
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))
end
Screen
Item            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).

Brass
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))
Screen
                  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.

Brass
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"))
Screen
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.

Brass
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
Screen
> 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():

Brass
while true do
  write("> ")
  run(read())
end

A 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:

Brass
-- 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)
Screen
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:

Brass
print("The northern line is closed until the Mechanical Drills finish the new tunnel.")
Screen
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:

Brass
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)
end
Screen
The 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 Luain Brass
s:gsub("_", " ")table.concat(s:split("_"), " ")
s:match("^%s*(.-)%s*$")s:trim()
for w in s:gmatch("%S+") dofor _, 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.

Brass
local function replace(text, old, new)
  return table.concat(text:split(old), new)
end
print(replace("iron_ingot", "_", " "))
print(replace("north--south--east", "--", " > "))
Screen
iron ingot
north > south > east

Squeeze repeated spaces into one:

Brass
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 ") .. "]")
Screen
[too many spaces]

Take the first number out of a text, as a sensor or another mod might write it:

Brass
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"))
Screen
128
72.5
nil

Count the occurrences of a text: the number of pieces after a split, minus one.

Brass
local route = "depot>mine>depot>farm>depot"
print(#route:split("depot") - 1)
Screen
3
Note

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 that split returns and table.concat joins.
  • Errors and debugging: what the error messages above mean, and how to catch them.
  • fs: reading and writing text files.