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 apostrophe and backtick, 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 case and letter
helpers understand every script: str.lower("ÉCOLE") is
"école", str.upper("привет") is
"ПРИВЕТ", and is_alpha("é") and
is_alnum("日") are true. Casing maps one
character to one character, so ß stays ß in
uppercase (not SS), a Turkish İ lowercases to
a plain i, and Σ always lowercases to
σ, even at the end of a word. Two deliberate exceptions
stay ASCII: is_digit accepts only
0–9, because it answers "will
int.parse read this?", and the naming-style converters
(to_snake_case and friends) split words on ASCII case
changes, because they are for identifiers.
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 } (a line ends at \n,
\r\n or a lone \r) |
|
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) |
upper / lower cased, in every script |
str.ascii_upper(s) /
str.ascii_lower(s) |
only A–Z changed, everything else left alone: use these for protocol words | |
str.title(s) |
each word's first letter uppercased, the rest lower; a word is a run of letters in any script | |
str.capitalize(s) |
first character uppercased, the rest unchanged | |
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) |
whether s is non-empty and all ASCII digits
0–9 (exactly what int.parse
reads) |
|
str.is_alpha(s) / str.is_alnum(s) /
str.is_space(s) |
whether s is non-empty and all letters / letters or
decimal digits / white space, in any script |
|
str.is_identifier(s) |
whether s can be written as a name: identifier
characters, and not a reserved word such as end |
Compare protocol words with str.ascii_lower, not
str.lower. Header names, HTML tag names and
keywords are defined in ASCII, but str.lower works in every
script, and a few letters fold onto plain ASCII ones: İ
becomes i and the Kelvin sign K becomes
k (and str.upper turns ı into
I and ſ into S). So
str.lower("X-APİ-KEY") == "x-api-key" is true, and a
look-alike gets past a check written that way.
str.ascii_lower changes only A–Z,
so the look-alike stays different:
import str
print(str.lower("X-APİ-KEY") == "x-api-key")
print(str.ascii_lower("X-APİ-KEY") == "x-api-key")
$ scua lookalike.scua
true
false
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)
Bytes and text never mix silently: "id: " .. b is an
error. An interpolation hole is different, because it displays
a value, so `id: {b}` shows the bytes the way
print does (id: bytes(486921)). A value over
256 bytes shows its first 16 and a count of the rest
(bytes(00000000000000000000000000000000, … 65520 more));
b.to_hex() gives every byte. To put the text the bytes hold
into a string, convert it.
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
Every function in the module is also a method on a bytes
value: b.to_array(), b.to_string(),
b.slice(start, end), b.to_hex() and the rest,
with bytes.name(b, …) as the other spelling; calling a
method needs no import bytes, only the value. Reading a
field that isn't one of them (b.length) says so. 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.u8/u16le/u32be/f64le/…
at a byte offset). Slicing and bytes.find cost only the
part they return, not the size of the buffer, so reading a large buffer
piece by piece stays fast. 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
const 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.
#Compressed data
bytes.inflate decompresses DEFLATE data, the compression
inside PNG images, gzip files, zip archives and HTTP. It reads a zlib
stream by default; pass { format = "gzip" } for a
.gz file or { format = "raw" } for a zip
entry. The input usually came from outside the program, so the answer is
a Result: damaged or truncated data is an Error that says
what was wrong. A small file can expand to a huge one, so when the input
isn't yours, cap the output with { max_bytes = N }, which
is checked as it grows:
import bytes
let packed = bytes.from_hex("789ccb48cdc9c9d751c840a200442806d5")?
match bytes.inflate(packed)
Ok(raw) -> print(bytes.to_string(raw)?)
Error(e) -> print(e)
end
match bytes.inflate(packed, { max_bytes = 5 })
Ok(raw) -> print(len(raw))
Error(e) -> print(e)
end
$ scua inflate.scua
hello, hello, hello
bytes.inflate: the output is over the 5 bytes `max_bytes` allows
When you know how big the result will be, say so with
{ size = N }. A PNG header gives it: for an image that
isn't interlaced, it's the height times one more than the bytes in a
row. bytes.inflate then writes straight into a value of
that size, so a large image costs its compressed and decompressed sizes
and no more, and it's an Error if the data comes to any
other length. A gzip file records its length, so
{ format = "gzip" } does this for you. Without a size the
output is built in a growing buffer and copied at the end, which briefly
needs twice the result.
A stream doesn't have to sit in one piece. A PNG splits its
compressed image data across many IDAT chunks, each with a
header before it and a checksum after, and
{ ranges = [[start, end], ...] } decodes the stream those
pieces make, in order, without joining them into a copy first. Add
{ as = "u8" } to get the pixels as a { u8 }
array you can index and change, written in place when size
is given (without a size it is converted at the end, which costs a
copy):
import bytes
let packed = bytes.deflate(bytes.from_string("hello, hello, hello"))
-- The stream in two chunks, with other bytes around them, the way a PNG file holds it.
let file = b"head" .. bytes.slice(packed, 0, 8) .. b"crc!" .. bytes.slice(packed, 8, len(packed)) .. b"end"
let pixels = bytes.inflate(file, { ranges = [[4, 12], [16, len(packed) + 8]], size = 19, as = "u8" })?
print(elem_kind(pixels), len(pixels), pixels[0])
$ scua ranges.scua
u8 19 104
bytes.deflate goes the other way:
bytes.deflate(data, { level = 6 }) compresses to a zlib
stream, and format picks "gzip" or
"raw" as inflate's does. level
runs from 0, which stores the data without compressing it, to 9, the
smallest and slowest; 6 is the default. The data can be a
bytes value or a { u8 } array, and
{ from = i, to = j } compresses just that range of it, so
an image encoder can compress the rows it built without copying them out
first. The same input and options give the same bytes on the same
version of SCUA.
When the data is made a piece at a time, compress it as it comes
instead of building it whole first. bytes.deflater(opts)
takes the same format and level;
bytes.deflate_push(d, data) feeds it a bytes
value or a { u8 } (with { from, to } for part
of one), as often as you like; and bytes.deflate_finish(d)
gives the compressed bytes. They are exactly the bytes
bytes.deflate gives for all the pieces joined, so an image
encoder can filter and compress one band of rows at a time and hold only
the image and its output.
import bytes
let d = bytes.deflater({ level = 9 })
for word in ["hello ", "hello ", "hello ", "hello"] do
bytes.deflate_push(d, bytes.from_string(word))
end
let packed = bytes.deflate_finish(d)
print(packed == bytes.deflate(bytes.from_string("hello hello hello hello"), { level = 9 }))
$ scua deflater.scua
true
import bytes
let text = bytes.from_string("hello hello hello hello")
let packed = bytes.deflate(text, { level = 9 })
print(len(text), len(packed), bytes.inflate(packed)? == text)
$ scua deflate.scua
23 15 true
For pixel or sample data, bytes.to_array(b, "u8") turns
a buffer into a typed { u8 } array in one copy, one byte an
element, and bytes.from_array takes it back the same
way.
#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.