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, \" 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: 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 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.