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.
Characters and codepoints
Section titled “Characters and codepoints”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 = 1while String.byte(word, i) != nil do println(i .. ": " .. (String.byte(word, i) ?? 0)) i = i + 1endString.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)) -- Hiprintln(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)) -- HiString.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: yend
-- Counting characters that match something.local vowels: integer = 0for ch in String.iter("saule") do if String.contains("aeiou", ch) then vowels = vowels + 1 endendprintln(vowels) -- 3Length and slicing
Section titled “Length and slicing”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 charactersprintln(#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)) -- aulprintln(String.sub(word, 3)) -- ule, to the endprintln(String.sub(word, -3)) -- ule, the last threeprintln(String.sub(word, -3, -2)) -- ulprintln(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.sauendString.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)) -- abababprintln(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")) -- sauleprintln(String.lower("Mixed Case")) -- mixed caseprintln(String.lower("ŠALTIS")) -- šaltis, past ASCII too
-- Case-insensitive comparison, ASCII only.local typed: string = "YES"println(String.lower(typed) == "yes") -- trueString.upper(s: string) -> string
Uppercases s, Unicode-aware in the same way as String.lower.
println(String.upper("saule")) -- SAULEprintln(String.upper("Mixed Case")) -- MIXED CASEprintln(String.upper("saulė")) -- SAULĖ
-- Initial-capitalising a word.local name: string = "ada"println(String.upper(String.sub(name, 1, 1)) .. String.sub(name, 2)) -- AdaSearching
Section titled “Searching”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)) -- Sauleend
-- 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 = 1while true do local at: integer? = String.indexOf(text, ",", start) if at == nil then break end println("comma at " .. at!) -- 2, then 4 start = at! + 1endString.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) -- 3println(String.indexOf("hello", "z") == nil) -- trueprintln(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/binendString.contains(s: string, needle: string) -> boolean
Whether needle appears anywhere in s. An empty needle is always present.
println(String.contains("Saule", "aul")) -- trueprintln(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 endendString.starts(s: string, prefix: string) -> boolean
Whether s begins with prefix. An empty prefix is always present.
println(String.starts("Saule", "Sau")) -- trueprintln(String.starts("Saule", "aul")) -- false, not at the startprintln(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: verboseendString.ends(s: string, suffix: string) -> boolean
Whether s ends with suffix. An empty suffix is always present.
println(String.ends("main.sau", ".sau")) -- trueprintln(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")endSplitting
Section titled “Splitting”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) -- 3println(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 amberJoining
Section titled “Joining”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, cprintln(String.join(" | ", {1, 2, 3})) -- 1 | 2 | 3println(String.join("", {"a", "b"})) -- ab
-- Round-tripping a split.local csv: string = "x,y,z"println(String.join(",", String.split(csv, ",")) == csv) -- trueTrimming
Section titled “Trimming”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") -- trueString.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))) -- 4String.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]Replacing
Section titled “Replacing”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/cprintln(String.replace("a.b.c", ".", "/", 1)) -- a/b.c, first onlyprintln(String.replace("aaa", "a", "")) -- (empty), deletingprintln(String.replace("abc", "", "-")) -- abc, unchanged
-- The `.` is a full stop, not "any character".println(String.replace("a1b", ".", "!")) -- a1b, nothing matchedPadding
Section titled “Padding”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")) -- 007println(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, 7endString.padEnd(s: string, width: integer, fill: string?) -> string
The same, padding on the right.
println(String.padEnd("7", 3, "0")) -- 700println(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 : setendFormatting
Section titled “Formatting”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 itemsprintln(String.format("%.2f", 3.14159)) -- 3.14println(String.format("%-6s|", "hp")) -- hp |println(String.format("%06.2f", 3.14159)) -- 003.14println(String.format("%x and %X", 255, 255)) -- ff and FFprintln(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]Patterns
Section titled “Patterns”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.
Pattern syntax
Section titled “Pattern syntax”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 |
Two differences from Lua, on purpose
Section titled “Two differences from Lua, on purpose”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.21end
-- 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 Sauleend
-- A miss is nil, so `assert` is the short way to a definite table.println(String.match("no digits here", "%d+") == nil) -- truelocal 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) -- trueprintln(String.match("hello", "^e") == nil) -- trueprintln(assert(String.match("abc abc", "%a+", 5))[1]) -- abcString.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 foxendprintln("")
-- 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] = nendprintln(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 = 0for _ in String.gmatch(doc, "[^\n]+") do count = count + 1endprintln(count) -- 3A 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) -- 2An 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.