Skip to content
Saule
Docs

String

UTF-8 aware text utilities. Every index is 1-based and counted in characters, not bytes, so String.sub can never cut a multi-byte character in half. A negative index counts from the end, where -1 is the last character.

Most functions here operate on literal text: the . in String.replace(s, ".", "-") is a full stop, not “any character”. The three in Patterns — String.match, String.gmatch and String.gsub — read their pattern argument as a Lua-style pattern instead, where . is any character. Which language an argument is written in is decided by the function you call, never by a flag.

String.byte(s: string, i: integer?) -> integer?

The Unicode codepoint of the i-th character, or nil when i is outside the string. i defaults to 1, so a bare String.byte(s) reads the first character.

Nullable on purpose: a scan can walk off the end and stop, rather than needing a length check in front of every read.

local word: string = "Saule"
println(String.byte(word) ?? 0) -- 83, the codepoint of "S"
println(String.byte(word, 2) ?? 0) -- 97, the codepoint of "a"
println(String.byte(word, 99) == nil) -- true, past the end
-- Walking a string codepoint by codepoint, stopping at the end.
local i: integer = 1
while String.byte(word, i) != nil do
println(i .. ": " .. (String.byte(word, i) ?? 0))
i = i + 1
end
String.char(...integer) -> string

Builds a string from codepoints. The inverse of String.byte.

Note what this is not: String.char(0x80) is the two-byte UTF-8 encoding of U+0080, because the result is text. For the single byte 0x80, reach for Bytes.of.

println(String.char(72, 105)) -- Hi
println(String.char(83, 97, 117, 108, 101)) -- Saule
-- Codepoints past ASCII work the same way.
println(String.char(0x160)) -- Š
-- Round trip, through `byte` and back.
local source: string = "Hi"
println(String.char(String.byte(source, 1) ?? 0, String.byte(source, 2) ?? 0)) -- Hi
String.iter(s: string) -> fn() -> (string?, integer?)

A step closure over the characters of s, yielding each character and its 1-based index. Written for for, which stops when the closure answers nil.

for ch, i in String.iter("hey") do
println(i .. ": " .. ch) -- 1: h, then 2: e, then 3: y
end
-- Counting characters that match something.
local vowels: integer = 0
for ch in String.iter("saule") do
if String.contains("aeiou", ch) then
vowels = vowels + 1
end
end
println(vowels) -- 3
String.len(s: string) -> integer

The number of characters, which is not the number of bytes. #s is not defined for a string — use this, and use #Bytes.fromString(s) when the byte count is what you want.

local word: string = "saulė"
println(String.len(word)) -- 5 characters
println(#Bytes.fromString(word)) -- 6 bytes: "ė" takes two
-- Which is why slicing is by character, not by byte.
println(String.sub(word, 5, 5)) -- ė
String.sub(s: string, from: integer, to: integer?) -> string

The substring from from to to, both inclusive and 1-based. to defaults to the end of the string. Either index may be negative to count from the end, and both clamp, so an out-of-range slice is empty rather than an error.

local word: string = "Saule"
println(String.sub(word, 2, 4)) -- aul
println(String.sub(word, 3)) -- ule, to the end
println(String.sub(word, -3)) -- ule, the last three
println(String.sub(word, -3, -2)) -- ul
println(String.sub(word, 10, 20)) -- (empty, clamped)
-- Trimming a known prefix.
local path: string = "src/main.sau"
if String.starts(path, "src/") then
println(String.sub(path, 5)) -- main.sau
end
String.rep(s: string, n: integer) -> string

s repeated n times, with nothing between the copies. n of zero or less gives the empty string.

println(String.rep("ab", 3)) -- ababab
println(String.rep("-", 20)) -- --------------------
println(String.rep("x", 0)) -- (empty)
-- A quick ruler under a heading.
local title: string = "Report"
println(title)
println(String.rep("=", String.len(title))) -- ======
String.lower(s: string) -> string

Lowercases s. Unicode-aware, not ASCII-only: Š folds to š and Ė to ė, so this is correct for text in any language the string can hold.

println(String.lower("SAULE")) -- saule
println(String.lower("Mixed Case")) -- mixed case
println(String.lower("ŠALTIS")) -- šaltis, past ASCII too
-- Case-insensitive comparison, ASCII only.
local typed: string = "YES"
println(String.lower(typed) == "yes") -- true
String.upper(s: string) -> string

Uppercases s, Unicode-aware in the same way as String.lower.

println(String.upper("saule")) -- SAULE
println(String.upper("Mixed Case")) -- MIXED CASE
println(String.upper("saulė")) -- SAULĖ
-- Initial-capitalising a word.
local name: string = "ada"
println(String.upper(String.sub(name, 1, 1)) .. String.sub(name, 2)) -- Ada
String.find(s: string, needle: string, from: integer?) -> (integer?, integer?)

The 1-based start and end indices of the first occurrence of needle, or nil, nil when there is none. from says where to start looking and defaults to 1.

Two values, so you can slice around the match without measuring it. For just the start index, String.indexOf answers the same question with one value. needle is literal text; for the pattern form see String.match and Patterns.

local line: string = "name = Saule"
local s: integer?, e: integer? = String.find(line, " = ")
if s != nil and e != nil then
println(String.sub(line, 1, s! - 1)) -- name
println(String.sub(line, e! + 1)) -- Saule
end
-- A miss answers nil for both.
local missS: integer?, missE: integer? = String.find(line, "zzz")
println(missS == nil and missE == nil) -- true
-- `from` walks through every occurrence. `String.indexOf` is the one-value
-- form, which is what a loop like this wants. The match is declared *inside*
-- the loop: a nullable narrowed by `!= nil` stays narrowed for the rest of
-- the block, so it cannot be assigned a nullable again.
local text: string = "a,b,c"
local start: integer = 1
while true do
local at: integer? = String.indexOf(text, ",", start)
if at == nil then
break
end
println("comma at " .. at!) -- 2, then 4
start = at! + 1
end
String.indexOf(s: string, needle: string, from: integer?) -> integer?

The 1-based character index where needle first appears, or nil when it does not. from says where to start and defaults to 1.

String.find answers the same question and also gives the end index.

println(String.indexOf("hello", "ll") ?? 0) -- 3
println(String.indexOf("hello", "z") == nil) -- true
println(String.indexOf("a,b,a", "a", 2) ?? 0) -- 5, searching from 2
-- Splitting on the first separator only.
local setting: string = "path=/usr/local/bin"
local at: integer? = String.indexOf(setting, "=")
if at != nil then
println(String.sub(setting, 1, at! - 1)) -- path
println(String.sub(setting, at! + 1)) -- /usr/local/bin
end
String.contains(s: string, needle: string) -> boolean

Whether needle appears anywhere in s. An empty needle is always present.

println(String.contains("Saule", "aul")) -- true
println(String.contains("Saule", "xyz")) -- false
-- Filtering a list of names.
local names: table<string> = {"ada.sau", "readme.md", "main.sau"}
for name in names do
if String.contains(name, ".sau") then
println(name) -- ada.sau, then main.sau
end
end
String.starts(s: string, prefix: string) -> boolean

Whether s begins with prefix. An empty prefix is always present.

println(String.starts("Saule", "Sau")) -- true
println(String.starts("Saule", "aul")) -- false, not at the start
println(String.starts("Saule", "")) -- true
-- Routing on a prefix.
local arg: string = "--verbose"
if String.starts(arg, "--") then
println("long flag: " .. String.sub(arg, 3)) -- long flag: verbose
end
String.ends(s: string, suffix: string) -> boolean

Whether s ends with suffix. An empty suffix is always present.

println(String.ends("main.sau", ".sau")) -- true
println(String.ends("main.sau", ".lua")) -- false
-- Picking a handler by file extension.
local file: string = "notes.md"
if String.ends(file, ".md") then
println("markdown")
elseif String.ends(file, ".sau") then
println("saule")
end
String.split(s: string, sep: string) -> table<string>

Splits s on every occurrence of sep. The result always holds one more piece than there were separators, so splitting the empty string gives one empty piece rather than none. An empty sep splits into single characters.

local parts: table<string> = String.split("id,name,score", ",")
println(#parts) -- 3
println(parts[2] ?? "") -- name
-- The piece count is always separators + 1.
println(#String.split("a,,b", ",")) -- 3: "a", "", "b"
println(#String.split("", ",")) -- 1, one empty piece
-- An empty separator gives characters.
println(#String.split("abc", "")) -- 3
-- Parsing a simple key=value line.
local pair: table<string> = String.split("colour=amber", "=")
println((pair[1] ?? "") .. " is " .. (pair[2] ?? "")) -- colour is amber
String.join<V>(sep: string, parts: table<V>) -> string

Joins parts with sep between them, rendering each element the way tostring would. The same operation as Table.concat, with the arguments the other way round so the call reads as a sentence.

println(String.join(", ", {"a", "b", "c"})) -- a, b, c
println(String.join(" | ", {1, 2, 3})) -- 1 | 2 | 3
println(String.join("", {"a", "b"})) -- ab
-- Round-tripping a split.
local csv: string = "x,y,z"
println(String.join(",", String.split(csv, ",")) == csv) -- true
String.trim(s: string) -> string

Removes whitespace from both ends. Unicode whitespace, not only the ASCII space — a non-breaking space goes too.

println("[" .. String.trim(" hi ") .. "]") -- [hi]
println("[" .. String.trim("\t hi \n") .. "]") -- [hi]
println("[" .. String.trim(" ") .. "]") -- []
-- Cleaning a line of input before testing it.
local typed: string = " yes "
println(String.trim(typed) == "yes") -- true
String.trimStart(s: string) -> string

Removes whitespace from the start only.

println("[" .. String.trimStart(" hi ") .. "]") -- [hi ]
-- Measuring indentation.
local line: string = " indented"
println(String.len(line) - String.len(String.trimStart(line))) -- 4
String.trimEnd(s: string) -> string

Removes whitespace from the end only.

println("[" .. String.trimEnd(" hi ") .. "]") -- [ hi]
-- Dropping a trailing newline without touching leading indentation.
local line: string = " code\n"
println("[" .. String.trimEnd(line) .. "]") -- [ code]
String.replace(s: string, from: string, to: string, limit: integer?) -> string

Replaces every occurrence of from with to, or only the first limit of them when limit is given. Literal text, not a pattern. An empty from matches nothing and returns s unchanged, so this can never loop forever.

For the pattern form — where . is any character and (…) captures — see String.gsub. The two are separate functions rather than one with a flag, so that neither has an argument whose language you have to remember.

println(String.replace("a.b.c", ".", "/")) -- a/b/c
println(String.replace("a.b.c", ".", "/", 1)) -- a/b.c, first only
println(String.replace("aaa", "a", "")) -- (empty), deleting
println(String.replace("abc", "", "-")) -- abc, unchanged
-- The `.` is a full stop, not "any character".
println(String.replace("a1b", ".", "!")) -- a1b, nothing matched
String.padStart(s: string, width: integer, fill: string?) -> string

Pads s on the left until it is width characters long. fill defaults to a single space and repeats if it is longer than one character. Never truncates: a string already at or past width comes back unchanged.

println(String.padStart("7", 3, "0")) -- 007
println(String.padStart("7", 3)) -- " 7"
println(String.padStart("toolong", 3, "0")) -- toolong, never cut
-- Right-aligning a column of numbers.
for n in {5, 42, 7} do
println(String.padStart(tostring(n), 4, " ")) -- 5, 42, 7
end
String.padEnd(s: string, width: integer, fill: string?) -> string

The same, padding on the right.

println(String.padEnd("7", 3, "0")) -- 700
println(String.padEnd("hp", 6) .. "|") -- hp |
-- Laying out a two-column table without `String.format`.
local rows: table<string> = {"name", "id"}
for key in rows do
println(String.padEnd(key, 8) .. ": set") -- name : set, id : set
end
String.format(fmt: string, ...any) -> string

C-style formatting. The specifiers are %d and %i (integer), %f and %g (float), %s (any value, rendered as tostring would), %x and %X (hexadecimal), %o (octal), %c (a character from a codepoint) and %% for a literal percent sign.

Width, precision and flags work as they do in C: %-5s left-aligns in five columns, %05.1f pads with zeros to five columns with one decimal place.

println(String.format("%d items", 42)) -- 42 items
println(String.format("%.2f", 3.14159)) -- 3.14
println(String.format("%-6s|", "hp")) -- hp |
println(String.format("%06.2f", 3.14159)) -- 003.14
println(String.format("%x and %X", 255, 255)) -- ff and FF
println(String.format("%s scored %d%%", "Ada", 98)) -- Ada scored 98%
-- `%s` takes anything, including a class that implements `OpToString`.
println(String.format("[%s] [%s]", true, 1.5)) -- [true] [1.5]

String.find, String.replace and String.split all take literal text: String.replace(s, ".", "-") replaces full stops. The three functions in this group read their second argument as a pattern instead, where . means “any character”, %d means “a digit” and (…) captures part of the match.

These are Lua’s patterns, not regular expressions. There is no alternation (a|b) and no counted repetition (a{2,5}); what you get instead is a small language that fits in the table below, needs no separate engine, and is already familiar to anyone arriving from Lua.

A character class matches one character:

Class Matches
. any character
%a a letter
%d a digit, ASCII 0–9 only
%l a lowercase letter
%u an uppercase letter
%s whitespace
%w a letter or a digit
%x a hexadecimal digit
%p punctuation
%c a control character
%g any printable character except a space
%A, %D, %S, … the complement — any uppercase class letter negates it
[set] any character in the set: [aeiou], a range [a-z], or a class [%a_]
[^set] any character not in the set
%x where x is not a class letter the literal x — this is how you match %., %%, %(

A quantifier follows a class and repeats it:

Quantifier Meaning
* 0 or more, as many as possible
+ 1 or more, as many as possible
- 0 or more, as few as possible
? 0 or 1

And the rest of the language:

Item Meaning
^ anchor at the start of the subject — only meaningful as the first character
$ anchor at the end — only meaningful as the last character
(…) a capture: the text it matched is reported separately
%1–%9 match the same text capture 1–9 matched
%bxy a balanced pair: from x to its matching y, counting nesting — %b()
%f[set] a frontier: the empty position where the previous character is not in set and the next one is

Patterns run over characters, not bytes. A Saule string is always valid UTF-8 and is measured in codepoints, so . matches one character however many bytes it encodes to, and every index these functions report is a character index. In Lua, ("saulė"):gsub(".", "-") gives six dashes and can cut ė in half; here it gives five.

%a is Unicode-aware and %d is not. %a, %l, %u, %s and %w use the Unicode properties, so %a+ matches saulė and ąčę. %d is deliberately ASCII 0–9 only: a %d+ is almost always about to be read as a number, and the other digit systems a Unicode %d would match are not numbers any conversion would accept.

Not supported: a position capture, Lua’s (), which yields an integer index. Captures here are a table<string> and there is no honest integer to put in one — returning the digits as text would be a trap for whoever indexed with them. String.find already answers that question.

String.match(s: string, pat: string, from: integer?) -> table<string>?

The first match of pat in s, or nil when there is none. from says where to start looking and defaults to 1; a negative value counts back from the end.

On a match you get the captures, one element each, in the order they were opened. When the pattern declares no captures you get a one-element table holding the whole match — a pattern without parentheses captures itself, which is Lua’s rule, and it means m[1] is always “the interesting part”.

A table rather than two values, because of how this gets called: a single binding against a multi-return takes the whole tuple, so local m = String.match(s, p) would hand back a (string?, table<string>?) and every caller would have to destructure to reach anything.

-- No captures: the whole match, in one element.
local v: table<string>? = String.match("saule 26.21 linux", "%d+%.%d+")
if v != nil then
println(v![1]) -- 26.21
end
-- Captures: one element each.
local kv: table<string>? = String.match("name = Saule", "(%w+)%s*=%s*(%w+)")
if kv != nil then
println(kv![1] .. " is " .. kv![2]) -- name is Saule
end
-- A miss is nil, so `assert` is the short way to a definite table.
println(String.match("no digits here", "%d+") == nil) -- true
local sure: table<string> = assert(String.match("x=1", "%d"))
println(sure[1]) -- 1
-- `^` anchors to the start; `from` moves where that start is.
println(String.match("hello", "^h%a+") != nil) -- true
println(String.match("hello", "^e") == nil) -- true
println(assert(String.match("abc abc", "%a+", 5))[1]) -- abc
String.gmatch(s: string, pat: string) -> fn() -> (string?, table<string>?)

A step closure over every match, for for … in. Each call reports the whole match and its captures; the loop ends when there are no more.

Two values here rather than one, because a for is flexible about how many it binds — unlike a local. So one loop variable costs nothing and gets the common case right, and a second is there when you want the captures:

-- One variable: the whole match.
for word in String.gmatch("the quick brown fox", "%a+") do
print(word .. " ") -- the quick brown fox
end
println("")
-- Two: the whole match and its captures.
for whole, caps in String.gmatch("a=1, b=2, c=3", "(%a+)=(%d+)") do
println(whole .. " -> " .. caps![1] .. " is " .. caps![2])
end
-- a=1 -> a is 1
-- b=2 -> b is 2
-- c=3 -> c is 3
-- Collecting into a table.
local nums: table<string> = {}
for n in String.gmatch("10,20,30", "%d+") do
nums[#nums + 1] = n
end
println(String.join("+", nums)) -- 10+20+30
-- Walking the lines of a block of text: `[^\n]+` is "a run of non-newlines".
local doc: string = "first\nsecond\nthird"
local count: integer = 0
for _ in String.gmatch(doc, "[^\n]+") do
count = count + 1
end
println(count) -- 3

A zero-width match advances by one character rather than matching forever, so String.gmatch(s, "%a*") terminates.

An anchored pattern is an error here, not an empty loop. ^ can match in one place only, so iterating it is a mistake somewhere — and both of the things an implementation could quietly do instead are worse. Lua reads the ^ as a literal character, so its gmatch(s, "^a") silently yields nothing; yielding exactly one match would silently turn the loop into an if. Use String.match for a single anchored match.

String.gsub(s: string, pat: string, repl: string, limit: integer?) -> (string, integer)

Every match of pat in s replaced by repl, and how many replacements were made. limit caps the count; without it every match is replaced.

repl is a template, not literal text:

In repl Inserts
%1–%9 capture 1–9
%0 the whole match
%% a literal %

Any other %x is an error rather than a silent literal, because at that point it is far more likely to be a typo than an intent.

-- The count is the second value, so `local out, n = …`.
local out: string, n: integer = String.gsub("hello world", "o", "0")
println(out .. " (" .. n .. ")") -- hell0 w0rld (2)
-- Captures let you reorder what you matched.
local name: string, _c: integer = String.gsub("john smith", "(%a+) (%a+)", "%2, %1")
println(name) -- smith, john
-- `%0` is the whole match, `%%` a literal percent.
local tagged: string, _t: integer = String.gsub("ab", "%a", "[%0]")
println(tagged) -- [a][b]
local pct: string, _p: integer = String.gsub("98", "%d+", "%0%%")
println(pct) -- 98%
-- Collapsing runs of whitespace, the canonical use.
local tidy: string, _w: integer = String.gsub("too many spaces", "%s+", " ")
println(tidy) -- too many spaces
-- `limit` stops early; 0 replaces nothing.
local two: string, tn: integer = String.gsub("aaaa", "a", "b", 2)
println(two .. " (" .. tn .. ")") -- bbaa (2)
-- No match leaves the subject alone and reports 0.
local same: string, zero: integer = String.gsub("abc", "%d", "!")
println(same .. " (" .. zero .. ")") -- abc (0)
-- Counting without rewriting: replace a match with itself.
local _same: string, commas: integer = String.gsub("a,b,c", ",", ",")
println(commas) -- 2

An anchored pattern replaces at most once, at the start:

local fixed: string, fn2: integer = String.gsub("aaa", "^a", "b")
println(fixed .. " (" .. fn2 .. ")") -- baa (1)

Compare String.replace, which is the literal form and the right choice when the text you are replacing is just text.