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,
\r a carriage return, \e the ESC character
every ANSI terminal sequence starts with, \0 a NUL,
\" a literal quote, and \\ a literal
backslash. Braces are ordinary characters in a plain string; they're
only special in the backtick form below, where \{ and
\} write a literal brace.
\u{…} writes any character by its Unicode
codepoint, in hex: "caf\u{e9}" is
café and "\u{1F600}" is 😀. It takes 1–6 hex
digits, and it works in both string forms. Because a SCUA string is a
sequence of codepoints, this escape always adds exactly one
character however many bytes it takes.
print("caf\u{e9}")
print(len("caf\u{e9}"))
$ scua codepoints.scua
café
4
That list is the whole list. A backslash before anything else is a
compile error rather than a silently different string — writing
"\q" tells you so instead of quietly producing
q. str.char(n) does the same job as
\u{…} at runtime, from a number you compute.
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 }
The rule is worth stating precisely, because one half of it is
asymmetric. { must be escaped — it starts
a hole. } need not be: a lone
} is already literal. But }} is still read as
an escape, so two adjacent closing braces collapse to one:
print(`a }} b`) -- a } b (not "a }} b")
print(`{{"a": {{"b": 1}}}}`) -- {"a": {"b": 1}} correct: every brace doubled
So escaping consistently works, including for nested JSON. The trap
is text that happens to contain }} for some other reason,
where you wrote two braces and got one, with no error. Pasting raw
unescaped JSON usually fails loudly instead — {"a": 1} is
read as a hole and the : as a format spec — so the silent
case is narrow.
For JSON, build the value and encode it. It is shorter as well as immune:
import json
print(json.encode({ a = { b = 1 } }))
#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, from?) |
character index of the first match at or after from
(default 0), or nil |
str.rfind(s, needle, from?) |
character index of the last match starting at or before
from, 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.
To build one buffer out of many pieces, use
bytes.join — not bytes.concat in a
loop. concat takes two buffers, so joining N of
them means re-copying everything accumulated so far on every step, and
that gets slower and slower as the buffer grows. bytes.join
sizes the result once and copies each piece exactly once. The separator
is optional, because assembling a buffer usually doesn't want one:
import bytes
let chunks = [b"first", b"second", b"third"]
print(len(bytes.join(chunks))) -- 16: no separator
let line = bytes.join(chunks, b", ") -- with one
match bytes.to_string(line)
Ok(t) -> print(t)
Error(e) -> print(e)
end
$ scua joining.scua
16
first, second, third
Pieces may be bytes or strings, so you can mix a literal
into a run of buffers without converting it first. This is the shape you
want whenever you read a file or a socket in chunks and reassemble it —
see reading and writing
files.
#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.