SCUA

Manual

Strings and interpolation

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: d decimal integer, f fixed-point float, e scientific, x and X lowercase and uppercase hex, b binary, o octal, % percentage (the value times 100 with a % sign), and s string.

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.