Bytes
A bytes is a buffer of arbitrary bytes with no encoding promised, measured
and indexed in bytes. It is a primitive type alongside string, and the two
are not interchangeable: a string is text, always valid UTF-8, measured
in codepoints.
Reach for bytes whenever the data is not text — an image, a digest, a
compressed blob, a wire protocol. Reach for string for everything else.
local raw: bytes = Bytes.of(0x89, 0x50, 0x4e, 0x47)
println(#raw) -- 4, a byte countprintln(Bytes.toHex(raw)) -- 89504e47println(Bytes.toString(raw) == nil) -- true, not valid UTF-8Lua makes one type do both jobs, which is why #s there is a byte count and
s:sub(1, 1) can cut a character in half. Keeping them apart lets the
typechecker enforce the difference, so a buffer can never be mistaken for
text.
Two things bytes deliberately does not do. .. does not accept it —
concatenation builds a string, and a buffer that is not text has no string
form, so use Bytes.concat. And it cannot be a table key: key by
Bytes.toHex(b) instead. Equality is by contents, and a bytes is never
equal to a string.
Indices are 1-based, as everywhere else in Saule.
Building
Section titled “Building”Bytes.of(...byte) -> bytes
Builds a buffer from byte values. The arguments are typed
byte, so a literal outside 0..=255 is a
compile error rather than a runtime surprise, and a computed value has to
narrow with as byte first.
Note the difference from String.char: Bytes.of(0x80) is one byte,
where String.char(0x80) is the two-byte UTF-8 encoding of U+0080.
local png: bytes = Bytes.of(0x89, 0x50, 0x4e, 0x47)println(Bytes.toHex(png)) -- 89504e47println(#png) -- 4
-- One byte, not a character.println(#Bytes.of(0x80)) -- 1println(#Bytes.fromString(String.char(0x80))) -- 2
-- A computed value narrows explicitly.local n: integer = 200local b: byte? = n as byteprintln(Bytes.toHex(Bytes.of(b!))) -- c8Bytes.empty() -> bytes
The empty buffer. Useful as a starting value for an accumulator, and as the fallback when a read returns nothing.
println(#Bytes.empty()) -- 0println(Bytes.toHex(Bytes.empty())) -- (empty)
-- Accumulating.local acc: bytes = Bytes.empty()for n in {1, 2, 3} do acc = Bytes.concat(acc, Bytes.of((n as byte) ?? 0))endprintln(Bytes.toHex(acc)) -- 010203Bytes.concat(...bytes) -> bytes
Joins buffers in order. This is what .. would do if it accepted bytes —
it does not, because concatenation builds a string.
local a: bytes = Bytes.of(0x01, 0x02)local b: bytes = Bytes.of(0x03)
println(Bytes.toHex(Bytes.concat(a, b))) -- 010203println(Bytes.toHex(Bytes.concat(a, b, a))) -- 0102030102println(#Bytes.concat(Bytes.empty(), a)) -- 2
-- Building a framed message: one length byte, then the body. The length is-- parenthesised because `as` binds tighter than `#`.local body: bytes = Bytes.fromString("hi")local size: byte = ((#body) as byte) ?? 0println(Bytes.toHex(Bytes.concat(Bytes.of(size), body))) -- 026869Converting to and from string
Section titled “Converting to and from string”Bytes.fromString(s: string) -> bytes
The UTF-8 bytes of s. Always succeeds, because every Saule string is
already valid UTF-8.
println(Bytes.toHex(Bytes.fromString("Hi"))) -- 4869println(#Bytes.fromString("saulė")) -- 6 bytesprintln(String.len("saulė")) -- 5 characters
-- The byte count of a string, which `String.len` does not give you.local text: string = "héllo"println(#Bytes.fromString(text)) -- 6Bytes.toString(b: bytes) -> string?
The buffer as text, or nil when it is not valid UTF-8.
Nullable on purpose: there is no total function from bytes to text, and silently substituting U+FFFD would hide the problem at exactly the point it matters.
println(Bytes.toString(Bytes.of(0x68, 0x69)) ?? "?") -- hiprintln(Bytes.toString(Bytes.of(0xff)) == nil) -- true
-- The shape this is written for: decode, or fall back.local payload: bytes = Bytes.fromString("ok")local text: string = Bytes.toString(payload) ?? "<binary>"println(text) -- okInspecting
Section titled “Inspecting”Bytes.len(b: bytes) -> integer
The byte count. #b is the same thing, as a value you can pass around.
local raw: bytes = Bytes.of(1, 2, 3)println(Bytes.len(raw)) -- 3println(#raw) -- 3, the same numberprintln(Bytes.len(Bytes.empty())) -- 0Bytes.byte(b: bytes, i: integer) -> byte?
The byte at i, or nil when i is outside the buffer. The result is a
byte, which is in 0..=255 by construction
and widens to integer for free.
local raw: bytes = Bytes.of(0x7f, 0x80, 0xff)
println(Bytes.byte(raw, 1) ?? 0) -- 127println(Bytes.byte(raw, 2) ?? 0) -- 128println(Bytes.byte(raw, 9) == nil) -- true
-- Summing a buffer, stopping cleanly at the end.local total: integer = 0local i: integer = 1while Bytes.byte(raw, i) != nil do total = total + (Bytes.byte(raw, i) ?? 0) i = i + 1endprintln(total) -- 510Slicing
Section titled “Slicing”Bytes.sub(b: bytes, from: integer, to: integer?) -> bytes
The slice from from to to, both inclusive and 1-based. to defaults to
the end, negatives count from the end, and both ends clamp — so an
out-of-range slice is empty rather than an error.
local raw: bytes = Bytes.of(1, 2, 3, 4, 5)
println(Bytes.toHex(Bytes.sub(raw, 2, 4))) -- 020304println(Bytes.toHex(Bytes.sub(raw, 3))) -- 030405println(Bytes.toHex(Bytes.sub(raw, -2))) -- 0405println(#Bytes.sub(raw, 99, 120)) -- 0, clamped
-- Splitting a header off a payload.local frame: bytes = Bytes.of(0x02, 0xaa, 0xbb)println(Bytes.toHex(Bytes.sub(frame, 1, 1))) -- 02, the length byteprintln(Bytes.toHex(Bytes.sub(frame, 2))) -- aabb, the bodySearching
Section titled “Searching”Bytes.indexOf(b: bytes, needle: bytes, from: integer?) -> integer?
The 1-based position where needle first appears, or nil when it does not.
from says where to start. An empty needle matches at from.
local raw: bytes = Bytes.of(0x00, 0x7f, 0x80, 0x7f)
println(Bytes.indexOf(raw, Bytes.of(0x80)) ?? 0) -- 3println(Bytes.indexOf(raw, Bytes.of(0x7f)) ?? 0) -- 2, the first oneprintln(Bytes.indexOf(raw, Bytes.of(0x7f), 3) ?? 0) -- 4, searching onprintln(Bytes.indexOf(raw, Bytes.of(0xee)) == nil) -- true
-- Finding a delimiter in a buffer.local packet: bytes = Bytes.fromString("key=value")local at: integer? = Bytes.indexOf(packet, Bytes.fromString("="))if at != nil then println(Bytes.toString(Bytes.sub(packet, 1, at! - 1)) ?? "") -- keyendHexadecimal
Section titled “Hexadecimal”Bytes.toHex(b: bytes) -> string
Lowercase hexadecimal, two characters per byte.
This is how a buffer gets looked at — print(b) shows only <bytes len=N> —
and how one becomes a table key, since bytes is not keyable itself.
println(Bytes.toHex(Bytes.of(0, 255))) -- 00ffprintln(Bytes.toHex(Bytes.of(0xde, 0xad))) -- deadprintln(Bytes.toHex(Bytes.empty())) -- (empty)
-- Keying a table by a buffer's contents.local seen: table<string, boolean> = {}local digest: bytes = Bytes.of(0x01, 0x02)seen[Bytes.toHex(digest)] = trueprintln(seen["0102"] ?? false) -- trueBytes.fromHex(s: string) -> bytes?
Parses hexadecimal back into a buffer. nil for an odd number of digits or
any non-hex character, rather than a buffer that is quietly half right.
println(Bytes.toHex(Bytes.fromHex("00ff")!)) -- 00ffprintln(Bytes.fromHex("0f0") == nil) -- true, odd lengthprintln(Bytes.fromHex("zz") == nil) -- true, not hex
-- Round trip.local raw: bytes = Bytes.of(0xca, 0xfe)println(Bytes.toHex(Bytes.fromHex(Bytes.toHex(raw))!) == Bytes.toHex(raw)) -- trueByte arrays
Section titled “Byte arrays”Bytes.toArray(b: bytes) -> byte[]
The buffer as one element per byte. The expensive direction — every byte becomes a boxed table element — and explicit about it.
local raw: bytes = Bytes.of(10, 20, 30)local list: byte[] = Bytes.toArray(raw)
println(#list) -- 3println(list[2] ?? 0) -- 20
-- Which makes the whole sequence API available.println(Iter.reduce(list, 0, (acc, n) => acc + n)) -- 60Bytes.fromArray(a: byte[]) -> bytes?
Packs elements back into a buffer. nil when an element is out of range —
which the typechecker prevents for a byte[], but not for a table that
arrived through any.
println(Bytes.toHex(Bytes.fromArray({1, 2, 3})!)) -- 010203println(#Bytes.fromArray({})!) -- 0
-- Round trip through the elementwise form.local raw: bytes = Bytes.of(0xaa, 0xbb)println(Bytes.toHex(Bytes.fromArray(Bytes.toArray(raw))!)) -- aabbbytes and byte[]
Section titled “bytes and byte[]”A bytes is the packed representation; a byte[] (that is,
table<integer, byte>) is the elementwise one. Bytes.toArray and
Bytes.fromArray convert between them, and they are two explicit functions
rather than an implicit conversion because the costs are not comparable: a
bytes is one byte per byte, while a table holds a boxed value per element.
Reach for bytes to hold or move bytes — a file, a socket frame, a digest,
anything crossing into a native package. Reach for byte[] only to work
through bytes one at a time, where a loop over elements is clearer than
repeated Bytes.byte calls.