Strings are text. You write a plain string with double quotes, and an
interpolated string with backticks, where {expr} holes are
filled in with values. A format spec after a colon controls how a value
is rendered.
let name = "Ed"
let level = 7
print(`{name} is level {level}`)
print(`next level: {level + 1}`)
Run it:
$ scua interp.scua
Ed is level 7
next level: 8
#String literals and escapes
A plain string is written with double quotes. Backslash escapes work
the usual way: \t is a tab, \n a newline,
\" a literal quote, and \\ a literal
backslash. Braces are ordinary characters in a plain string; they're
only special in the backtick form below.
print("plain text")
print("tab\tbetween")
print("two\nlines")
print("a quote: \" and a backslash: \\")
Run it:
$ scua literals.scua
plain text
tab between
two
lines
a quote: " and a backslash: \
#Length, indexing, and iteration
A string is Unicode text, and SCUA works with it one
character at a time, not one byte. len(s)
counts characters, s[i] gives the i-th
character (counting from 0) as a one-character string, and
for c in s walks the characters in order. So international
text behaves the way you'd expect — "café" is four
characters even though it takes five bytes to store.
let s = "café"
print(len(s)) -- 4, not 5
print(s[0], s[3]) -- c é
for c in s do
print(c)
end
Run it:
$ scua chars.scua
4
c é
c
a
f
é
You can also pair the character with its index using
for i, c in s, where i counts characters from
0. Indexing past the end of the string is an out-of-bounds error, the
same as for an array.
For plain ASCII text a character is one byte, so this is all fast and
obvious. A character outside ASCII (an accent, a CJK glyph, an emoji)
takes more than one byte to store, but it's still a single character to
len, s[i], and for. When you
genuinely need the raw bytes — writing a binary format, hashing —
str.bytes(s) gives them to you (see below).
#Interpolation
A backtick string fills each {expr} with the value of
that expression. The expression can be anything, not just a name:
let name = "Ed"
let level = 7
print(`{name} is level {level}`)
print(`next level: {level + 1}`)
Run it:
$ scua interp.scua
Ed is level 7
next level: 8
To put a literal brace in a backtick string, double it.
{{ becomes { and }} becomes
}:
let score = 1234.5
print(`braces: {{ {score:.0f} }}`)
Run it:
$ scua braces.scua
braces: { 1235 }
#Format specs
Inside a hole you can add a format spec after a colon:
{value:spec}. The spec is
[align][0][width][.precision][type], and every part is
optional. It controls width, alignment, padding, precision, and how the
value is rendered.
let score = 1234.5
print(`plain: {score}`)
print(`fixed: {score:.2f}`)
print(`width: |{score:>12.2f}|`)
print(`zero: {42:05d}`)
print(`hex: {255:04x}`)
print(`binary: {10:b}`)
print(`percent: {0.875:.1%}`)
print(`align: |{"hi":<6}|{"hi":^6}|{"hi":>6}|`)
Run it:
$ scua format.scua
plain: 1234.5
fixed: 1234.50
width: | 1234.50|
zero: 00042
hex: 00ff
binary: 1010
percent: 87.5%
align: |hi | hi | hi|
The pieces:
- align is
<for left,>for right, or^for centered, padding with spaces to the width. - 0 right after the alignment pads numbers with
leading zeros instead of spaces, as in
{42:05d}. - width is the minimum field width.
- .precision sets the number of fractional digits for
f,e, and%. - type picks the rendering:
ddecimal integer,ffixed-point float,escientific,xandXlowercase and uppercase hex,bbinary,ooctal,%percentage (the value times 100 with a%sign), andsstring.
The padding character is fixed: spaces, or zeros when you use the
0 flag. There's no custom fill character, and there's no
thousands separator or sign flag, so specs like {n:*^8} or
{n:,d} are rejected as malformed. In an interpolated string
the spec is checked when you compile, so a typo like
{x:.2q} is a clear compile error rather than a surprise at
runtime.
#format()
format applies the same mini-language to build a string
from positional arguments. An empty {} takes the next
argument in order; a numbered {0} refers to a specific
argument and can repeat. A spec works the same as in interpolation.
print(format("{} beats {}, so {0} wins", "red", "blue"))
print(format("hp {}/{} ({:.0%})", 30, 40, 30.0 / 40.0))
Run it:
$ scua format-fn.scua
red beats blue, so red wins
hp 30/40 (75%)
#Joining and concatenation
Two strings join with the .. operator. To join a list of
strings with a separator, import the str module and use
str.join:
import str
print("foo" .. "bar")
print(str.join(["Ana", "Bo", "Cy"], " & "))
Run it:
$ scua join.scua
foobar
Ana & Bo & Cy
For most string-building, interpolation reads better than a chain of
...
#Building a string piece by piece
When you're assembling a string in a loop, reach for a
builder instead of
result = result .. piece each time round. Repeated
.. rebuilds the whole string on every step, which is
quadratic; a builder appends in amortized constant time and hands you
the finished string at the end.
let b = builder()
b.add("name,score\n")
for row in rows do
b.add(row.name, ",", row.score, "\n") -- add() takes any values and appends their text
end
print(b.build())
builder() makes a new buffer. b.add(...)
appends the text of each argument — the same rendering
print uses, so you can pass strings, numbers, anything —
and returns the builder, so calls chain:
builder().add("a").add("b"). b.build() gives
you the accumulated text as a string, and the builder stays usable if
you want to keep adding.
#String operations
The str module covers the common operations. Import it
with import str and call its functions with the module
prefix:
import str
print(str.upper("ready"))
print(str.lower("LOUD"))
print(str.trim(" hi "))
print(str.split("a,b,c", ","))
print(str.starts_with("scuamatic", "scua"))
print(str.ends_with("report.txt", ".txt"))
print(str.find("hello world", "world"))
print(str.replace("a-b-c", "-", "/"))
print(str.repeat("=", 8))
print(str.pad_start("7", 4, "0"))
print(len("hello"))
Run it:
$ scua str-ops.scua
READY
loud
hi
[a, b, c]
true
true
6
a/b/c
========
0007
5
A few notes. str.split returns an array.
str.find (and str.rfind for the last match)
returns the character index where the needle starts, or nil
if it isn't there; the 6 above is the position of
world in hello world.
str.pad_start(s, width, pad) pads on the left to the given
width in characters. Indices and widths everywhere in str
count characters, matching len and s[i].
Beyond the basics, the str module also covers:
searching (find, rfind,
contains, count), splitting
(split, lines, partition — which
cuts a string into [before, sep, after]), trimming
and shaping (trim,
pad_start/pad_end, dedent for
multi-line text), case (upper,
lower, title, and the naming-style converters
to_snake_case / to_kebab_case /
to_camel_case / to_pascal_case), and
validation (is_int, is_float,
is_digit, is_alpha, is_alnum,
is_space, is_identifier). The validation and
case helpers work on ASCII; full Unicode-aware case folding and
normalization are planned.
For pattern matching, the regex module
provides a ReDoS-safe regular-expression engine, and
str.matches(s, re) / str.find_re(s, re) /
str.replace_re(s, re, repl) run a compiled pattern over a
string — see Match text
with regular expressions.
| Category | Function | Result |
|---|---|---|
| Searching | str.find(s, needle) |
character index of the first match, or nil |
str.rfind(s, needle) |
character index of the last match, or nil |
|
str.contains(s, sub) |
bool |
|
str.count(s, sub) |
how many non-overlapping sub occur |
|
| Splitting | str.split(s, sep) |
{ string } |
str.lines(s) |
{ string } (splits on \n, tolerating
\r\n) |
|
str.partition(s, sep) |
{ string } — [before, sep, after] (or
[s, "", ""]) |
|
| Trimming & shaping | str.trim(s) / str.trim_start(s) /
str.trim_end(s) |
whitespace removed from both ends / the start / the end |
str.pad_start(s, width, fill?) /
str.pad_end(s, width, fill?) |
padded to width characters (fill default
space) |
|
str.dedent(s) |
common leading whitespace removed from every line | |
| Case | str.upper(s) / str.lower(s) |
ASCII upper / lower cased |
str.title(s) |
each word's first letter uppercased, the rest lower | |
str.capitalize(s) |
first character uppercased | |
str.to_snake_case(s) /
str.to_kebab_case(s) / str.to_camel_case(s) /
str.to_pascal_case(s) |
naming-style converters | |
| Validation | str.is_int(s) / str.is_float(s) |
whether s parses as an integer / a number |
str.is_digit(s) / str.is_alpha(s) /
str.is_alnum(s) / str.is_space(s) |
whether s is non-empty and all digits / letters /
letters-or-digits / whitespace |
|
str.is_identifier(s) |
whether s is a valid identifier |
The str module has more than this.
str.slice(s, start, end) takes a substring by character
index; str.chars(s) splits into an array of one-character
strings and str.reverse(s) flips them;
str.char(code) and str.code(s, i) convert
between a Unicode codepoint and a character. When you need the
underlying storage rather than characters, str.bytes(s)
returns the raw UTF-8 bytes. The pattern is the same throughout: import
once, then call with the str. prefix.
#Binary data: the bytes
type
A string is text. When you're dealing with
binary data instead — a file's raw contents, a network packet,
a hash, anything that isn't necessarily UTF-8 — use the
bytes type. A bytes value is
a sequence of raw bytes; indexing it gives an int (0–255),
not a character, and len counts bytes.
Write one inline with a b"…" byte
literal — a b immediately before a string. It
takes the same escapes as a regular string, and produces
bytes rather than text:
let ok = b"ok" -- 2 bytes
let empty = b"" -- 0 bytes
let line = b"a\nb" -- 3 bytes; \n is one byte
The b has to touch the opening quote to count — a bare
b, or a name like bird, stays an ordinary
identifier. Reach for b"…" when you have a literal payload
(an HTTP response body = b"ok", a magic-number header);
convert a runtime string with bytes.from_string
(below).
import bytes
let b = bytes.from_string("Hi!") -- the UTF-8 bytes of some text
print(len(b)) -- 3
print(b[0]) -- 72, an int (the byte 'H')
print(b.to_hex()) -- 486921 (the operation reads on the value; `bytes.to_hex(b)` also works)
Converting back to text is fallible, because arbitrary bytes aren't
always valid UTF-8 — so bytes.to_string returns an
Ok(string) or an Error you handle:
import bytes
match bytes.to_string(b)
Ok(text) -> print(text) -- Hi!
Error(why) -> print(why)
end
The module also converts to and from hex (bytes.from_hex
/ bytes.to_hex) and int arrays
(bytes.from_array / bytes.to_array), slices
(b.slice(start, end), also bytes.slice), and
reads fixed-width numbers out of a buffer for binary protocols
(bytes.u16le/u32be/f64le/… at a
byte offset). The file and network capabilities deal in
bytes: fs.read and net.read
return raw bytes, and fs.read_text decodes a file as UTF-8
text for you. See the bytes
example.
#Keys: fast interned identities
When you have a fixed set of names used as identifiers — entity tags,
animation states, config lookups, message kinds — a
key is a faster, clearer choice than a
plain string. A key is an interned string: equal keys share one
identity, so comparing two keys is a single O(1) check rather than a
character-by-character compare, and keys make efficient dictionary
keys.
Write a key literal with $"…", or build one from a
runtime string with key(s):
let state = $"idle"
if state == $"idle" then state = $"walking" end -- O(1) identity compare
let stats = {}
stats[$"health"] = 100 -- a key as a dictionary key
print(stats[$"health"])
match state
$"walking" -> print("on the move")
_ -> print("still")
end
A key is a distinct type from
string, so $"attack" is not equal to
"attack" — much like a symbol in other languages. That
keeps identifiers (keys) and text (strings) from being mixed up by
accident. You can still see a key's text: print($"attack")
shows attack, and type(k) is
"key". Keys flow across actor boundaries and persist like
any value. See the keys
example.