Collections and Enumerable
Language reference · Previous: Gradual typing and exceptions · Next: Runtime features and debugging
[1, 2] for arrays and {"key": value} for hashes, both with optional
type parameters, and both can be split across lines — a newline is
allowed right after the opening bracket, right after each ,, and
right before the closing bracket, the same as every other
bracket-delimited, comma-separated list in the language (call
arguments, def parameter declarations, generic type-argument lists,
and more):
config = {
"name": "myapp",
"values": [
1,
2,
],
}
An array literal can splat one Array into place: [head, *rest],
[*items, last], or [first, *middle, last] build a new Array (a
non-Array operand raises TypeError). Only one splat per literal is
supported; join more with concat.
A hash literal also accepts Ruby's rocket separator, {"a" => 1, 3 => "three"}, which means exactly the same as
{"a": 1}; the two can be mixed in one literal. The key is an expression either way, so {a => 1} uses the
value of a, and a bare name: value is not a symbol-key shorthand.
Array.new(n) builds an Array of n nils and Array.new(n, fill) one of n copies of fill
(Array.new(3, 0) is [0, 0, 0]). As in Ruby, every slot holds the same fill object, not a copy
of it: Array.new(2, []) is two references to one inner Array. n must be an Int (a TypeError,
or a compile error for a literal of another type) and not negative (ArgumentError).
Array.new(n) do |i| ... end instead fills each slot with the block's value for that index
(Array.new(3) do |i| i * i end is [0, 1, 4]).
Hash.new() is an empty Hash, and Hash.new(default) one whose missing keys read as default
instead of nil, so counts = Hash.new(0) then counts[word] += 1 counts words.
hash.default() returns it. Reading a missing key does not store it, every missing key answers
the same default object (so Hash.new([]) shares one Array, as in Ruby), and the default is fixed
when the Hash is made. dup and copies into another thread keep it; methods that build a new Hash
(merge, select, transform_values, ...) do not.
.push, .pop, and .length() are native on both. Array additionally
has .join(separator = ""), native and O(n) total (a StringBuilder-
backed accumulator internally, not repeated string concatenation) —
stringifies each element (the same formatting string interpolation
uses, including calling a user-defined to_s override) and joins them
with separator between (not trailing). .slice(start, length) works
the same way String#slice does (below): start in 0..count,
length >= 0, clamped to what's actually available rather than
erroring past the end. array[a..b] and array[a...b] take a Range the same way, and a
negative bound counts from the end (array[1..-1] is everything but the first element;
array[0...-1] everything but the last); a start before the beginning or past the end raises
IndexError.
== on both is real structural (not identity) comparison: Array by
length plus elementwise equality, order-sensitive; Hash by entry
count plus every key present in both with an equal value,
order-insensitive ({"a": 1, "b": 2} == {"b": 2, "a": 1} is true).
Recursive to a depth of 256 (DIAMOND_STRUCTURAL_MAX_DEPTH), deep
enough for any real nesting while still bounding a self-referential
Array/Hash (a = []; a.push(a)) — two different cyclic values
compare unequal rather than hanging, and a cyclic value compared to
itself short-circuits to true via an identity check before any
recursion happens. Hashing matches: an Array/Hash used as a Hash
key is hashed by the same structural content (Array order-sensitive,
Hash order-insensitive, mirroring their own ==), so h[[1, 2, 3]]
finds a value inserted through a different [1, 2, 3] object with
equal contents. Instance is unrelated to any of this — it's still
identity-only unless its class defines its own == (see
Classes and modules).
Array's Enumerable-style methods: .each/.select/.map/.reduce/
.count/.any?/.all? (shared with Hash, driven by .each),
.sort/.sort_by/.min/.max/.min_by/.max_by/.reject/.find/
.each_with_index/.sum, and .take(n)/.drop(n)/.flat_map/
.partition/.group_by/.zip(other)/.each_slice(n)/.each_cons(n)/
.tally/.take_while/.drop_while. any?, all?, none? and one?
without a block test the elements themselves (only nil and false fail);
min(n)/max(n) return the n smallest or largest as an Array;
sum(start) adds onto start instead of 0 (sum(0.0), sum("")), with
or without a block. sort and sort_by are stable merge sorts (O(n log n), and
sort_by calls its block once per element); a blockless sort of an array
that is all Ints or all Strings runs natively. sort() do |a, b| ... end orders by a block
that returns a negative, zero or positive Int. Arrays compare element by
element and then by length when sorting or taking a min/max, so
words.sort_by() do |w| [w.length(), w] end orders by length, then
alphabetically. Calling .lazy() on an Array, Range, or other Enumerable
returns a LazyEnumerator. Its map, select, and reject operations are
composable and deferred until a terminal runs; chained transforms do not
allocate intermediate Arrays. each, to_a, and force drain the pipeline.
take, find, any?, and all? short-circuit through the cooperative
each_until protocol; Arrays, Hash values, and Ranges stop pulling as soon as
the terminal has its answer. The remaining methods retain their eager
behavior. The last group returns its result directly —
.each_slice/.each_cons collect every slice/window into an
Array[Array] up front (with a block, each one is also passed to it),
.partition returns [matching, non_matching], .group_by a Hash keyed on the block's own result,
.tally a Hash of element to occurrence count, and .zip pads the
shorter array with Nil out to the receiver's own length ([1, 2, 3].zip([4, 5]) => [[1, 4], [2, 5], [3, nil]]), matching Ruby. The
Hash versions are listed under Prelude
methods below.
Strings support .length(), .index_of(needle) (position or nil),
.slice(start, length), .to_i()/.to_f() (lenient decimal parsing —
.to_f() additionally accepts exponent notation like "1e3" even
though d6's own float literals don't, and overflows to Infinity
rather than raising, unlike .to_i(); neither skips leading
whitespace, and "nan"/"inf" parse as 0.0, matching Ruby's
String#to_f), and .downcase()/.upcase() (ASCII-only case
conversion), and
.reverse() (byte-level, not UTF-8-grapheme-aware — consistent with
the rest of the language having no other Unicode awareness), and
.strip() (trims leading/trailing ASCII whitespace; .lstrip()/.rstrip()
trim only one side), .chomp() (removes one trailing "\n", "\r\n" or
"\r"; .chomp(suffix) removes suffix instead, when the string ends
with it), .capitalize() (first ASCII letter upper, the
rest lower), .chars() (an Array of one-byte strings, so a multi-byte UTF-8
character becomes several), and .empty?()
(zero length, like Array's and Hash's own empty?). .split(separator)
returns an Array of every piece around non-overlapping occurrences of
separator (an empty separator splits into one-character strings);
unlike Ruby, it keeps every piece including empty ones from consecutive
or leading/trailing separators (no trailing-empty suppression) — a
deliberate simplification, not an attempt at Ruby compatibility.
separator can also be a Regexp (see Runtime features):
splits on every match instead of a literal substring, collecting the text
between matches (and whatever's left after the last match, or the whole
string when there's no match at all). A zero-width match is skipped
rather than split on, matching .scan's own zero-width handling. Any
other separator type raises TypeError.
.ord() returns the first byte's value as an Int; an empty String
raises a rescuable IndexError. Int#chr() is its inverse, also
available as the global chr(code) (recognized the same way gets()
is, so shadowable by a local or top-level function), returning a
one-character String. A code outside 0..255 raises a rescuable
RangeError.
.format(values) is a sprintf-style formatter — values is either a
single value or an Array of them. format % values is the same thing,
spelled as in Ruby:
"Name: %s, Age: %d".format(["Alice", 30]) # => "Name: Alice, Age: 30"
"%05d".format(42) # => "00042"
"%-10s|".format("hi") # => "hi |"
"%.2f" % 3.14159 # => "3.14"
"%s=%d" % ["hits", 3] # => "hits=3"
Directives: %d/%i (Int or Float, truncated), %f (Float or
Int, default 6 decimal places, %.Nf for N), %x/%X/%o/%b
(Int, hex/octal/binary — %b has no C printf equivalent, hand-
rolled), %s (any value, via the same formatting string interpolation
uses, including a user-defined to_s override), and %% for a literal
%. - left-justifies and 0 zero-pads within a numeric width prefix
(%-10s, %05d); .N sets %f's precision. Every directive's
argument type is checked against what that directive actually needs
(TypeError on a mismatch); too few arguments raises ArgumentError,
extra arguments are silently ignored. Each directive is handled by
building a small, internally-chosen conversion string from the parsed
flags/width/precision and handing it to a real snprintf alongside
exactly one correctly-typed value — never your format string forwarded
into a C varargs call directly, which would be a real format-string
vulnerability given a d6 value's runtime type has no fixed
relationship to what a positionally-matched C conversion expects.
Strings also support [] with a single Int index, returning a new
one-character String (bounds-checked, IndexError outside the
string — the same as .slice(); a negative index counts from the end,
so s[-1] is the last byte), s[start, length], the same as
s.slice(start, length), and s[a..b] / s[a...b] with a Range (a negative bound counts from the
end, so s[1..-1] drops the first byte; a start outside the string raises IndexError). Unlike Array/Hash, []= on a String is
rejected outright with a TypeError (strings are immutable).
.repeat(n) (or s * n) returns a new String with the receiver
repeated n times (n == 0 → ""); a negative n raises a rescuable
RangeError.
.start_with?(prefix, ...)/.end_with?(suffix, ...) are plain literal
String checks (no Regexp support); given several candidates, they are
true when any one matches. .ljust(width, padding)/.rjust(width, padding) pad the receiver on the right/left with padding (repeated
and truncated as needed) until it reaches width, or return the
receiver unchanged if it's already at least width long; a negative
width or an empty padding both raise a rescuable ArgumentError
rather than looping forever. .tr(from, to) is Ruby-style
character-set translation: from/to support a-z-style ranges and a
leading ^ to negate from, with \ escaping a literal - or ^;
each character in the receiver found in from is replaced by the
character at the same position in to (a shorter to maps every
remaining from character onto its own last character, matching
Ruby), or deleted outright when to is "". An empty from raises
ArgumentError; a non-String argument to any of these raises
TypeError.
"hello".start_with?("he") # => true
"main.d6".end_with?(".rb", ".d6") # => true
"abc".ljust(6, "-") # => "abc---"
"abc".rjust(6, "-") # => "---abc"
"hello".tr("el", "ip") # => "hippo"
"hello".tr("aeiou", "*") # => "h*ll*"
Array#pack(template) and String#unpack(template) / unpack1 convert between values and
binary Strings: integers C c S s L l Q q (little endian), n N (big endian), v V
(little endian); strings a A Z; hex H; UTF-8 code points U; x (a null byte). A
directive takes a count or * for all that remain: [1, 513].pack("CS"),
data.unpack("N*"), "\xde\xad".unpack("H*"). unpack returns nil for an integer past the
end of the data. Floats and the _ ! < > modifiers are not supported; an unknown directive
raises ArgumentError.
Regexp literals: /pattern/ is the same as Regexp.new("pattern"), with the flags i
(ignore case), m (. matches newline) and x (extended) after the closing slash:
"a1 b22".scan(/[0-9]+/), /hello/i.match?(text), when /^cost/. A slash is a regex
only where an expression can start; right after a value (a name, number, string, ), ],
} or end) it is division, so x /2/ 3 stays arithmetic and puts /x/ needs
parentheses: puts(/x/). Write \/ for a slash inside the pattern. Literals do not
interpolate (#{} is a compile error; build the pattern with Regexp.new), and the
literal compiles each time it is evaluated, so hoist one out of a hot loop.
.sub(pattern, replacement)/.gsub(pattern, replacement) replace the
first/every match of pattern (a Regexp, or a String matched
literally, as in Ruby: "a.b".gsub(".", "-") is "a-b") with
replacement, which may contain Ruby-style backreferences (\0 the
whole match, \1.. a capture group; a literal backslash is \\).
Given a block instead of a replacement String, they pass each matched
String to the block and substitute what it returns (converted with
to_s): "a1 b22".gsub(Regexp.new("[0-9]+")) do |n| n.to_i() * 2 end
gives "a2 b44".
.scan(pattern) (a Regexp or a literal String) returns
an Array of every match: the matched String itself when pattern
has no capture groups, or an Array of that match's captures when it
does -- never a mix of both across one call, since a given Regexp
either has groups or doesn't:
"hello world".gsub(Regexp.new("o"), "0") # => "hell0 w0rld"
"2024-01-15".gsub(Regexp.new("(\\d+)-(\\d+)-(\\d+)"), "\\3/\\2/\\1") # => "15/01/2024"
"a1 b22 c333".scan(Regexp.new("[0-9]+")) # => ["1", "22", "333"]
"key1=val1;key2=val2".scan(Regexp.new("([a-z0-9]+)=([a-z0-9]+)"))
# => [["key1", "val1"], ["key2", "val2"]]
See the Regexp guide for pattern/option syntax.
.each(callback), and the Enumerable methods derived from it —
.select/.count/.any?/.all?/.reduce/.map — work as receiver
syntax on both arrays and hashes (a hash's Enumerable operates over
values, discarding keys), forwarding at runtime to ordinary d6
functions in the prelude:
[1, 2, 3, 4].select(is_even)
{"a": 1, "b": 2}.count(is_positive)
[1, 2, 3].reduce(0, add)
Native collections are extensible from d6 source without VM changes.
Defining array_name(values, ...), hash_name(values, ...), or the shared
fallback enumerable_name(values, ...) exposes .name(...) on the matching
receiver. A trailing predicate ? is omitted from the bridge function name,
so array_large(values, minimum) implements values.large?(minimum).
Built-in VM operations take precedence over extension bridges.
Any user-defined class gets those same six methods, plus to_a, sort,
sort_by, min, max, min_by, max_by, reject, find,
each_with_index, sum, take, drop, flat_map, partition,
group_by, zip, each_slice, each_cons, and tally, for free by
implementing its own each(callback) and include-ing Enumerable. The
first six forward straight to prelude functions driven by self.each(...),
so they're generic by construction; the rest reuse Array's own existing,
already-tested index-based implementations by materializing the receiver
into an Array via to_a first (itself built on each) and delegating to
those, rather than re-deriving each one generically over each().
For a user-defined class including Enumerable, blockless collection.sum()
adds the values yielded by each, starting at integer zero. Empty collections
return 0; mixed integer/float values produce a floating-point sum. Each call
traverses the collection again. The included method takes no arguments or block;
Array and Hash have their own block-taking sum implementations.
A handful of further Array/Hash conveniences work as receiver syntax too,
forwarding the same way the Enumerable set above does:
values.first()/values.last() return the first/last element (an empty
array's IndexError propagates straight from the underlying []);
values.first_or(fallback)/values.last_or(fallback) are the safe form,
returning fallback instead when values is empty; values.empty?()
returns whether the array has zero elements; values.include?(needle)
returns whether any element == needle;
values.index_of(needle) returns the position of the first such element,
or nil (like String#index_of); values.reverse() returns a new
array in reverse order; values.concat(other) (or values + other)
returns a new array with other's elements appended; values.compact() returns a new array with
any nil elements dropped; values.uniq() returns a new array with only
the first occurrence of each distinct (==) element, order preserved
(with a block, uniq() do |x| key end, elements are compared by the
block's key); values.flatten() returns a new array with nested arrays fully
flattened (recursively, matching Ruby's default), and values.flatten(depth)
only that many levels;
values.delete_at(index) mutates values in place (like the native
.push/.pop), removing and returning the element at index, or nil
without mutating if index is out of bounds.
hash.delete(key) removes key and returns its value, or nil when
key is absent (a frozen Hash raises FrozenError). The remaining entries
keep their insertion order, and a deleted key added again goes to the end.
It is native and O(n) in the Hash's size, since later entries move down to
keep the Hash dense; the result's static type is the Hash's value type or
Nil.
hash.clear() removes every pair and returns the same, now empty, Hash (a
frozen Hash raises FrozenError, and extra arguments raise ArgumentError).
It is native and does one O(n) pass over the Hash's table, where calling
delete for each key would cost far more; the removed values themselves are
not affected, only the Hash's references to them.
hash.fetch(key, fallback) returns the value at key, or fallback
(not raising) when key is absent; hash.keys()/hash.values() return
an Array of the hash's keys/values respectively, both in insertion
order; hash.include_key?(needle) returns whether needle is a key;
hash.map_values(callback) returns a new Hash with the same keys and
each value passed through callback; hash.empty?() returns whether the
hash has zero pairs (same name and meaning as Array's own); hash.merge(other)
returns a new Hash with the receiver's pairs then other's applied on top
(other wins on key conflicts, unless a block is given:
a.merge(b) do |key, old, new| old + new end stores what it returns).
Like the rest of the Enumerable set,
vm.c's native dispatch resolves each of these receiver calls to a
same-named top-level prelude function (array_reverse, array_concat,
...) at runtime, so that free-function spelling still exists underneath
and can't be removed without a native-dispatch rework -- receiver syntax
is simply the only spelling documented and used going forward.
array_join(values, separator = "") was the one exception: a thin
wrapper around the already-native .join() above rather than
.join()'s own implementation, with no dispatch dependency on its name,
so it has been removed now that values.join(separator) is the only
spelling.
Int/Float are scalar DiamondValues rather than heap objects, but native
dispatch provides a small method surface. Fixed duration helpers
.second(s)() through .week(s)() return numeric seconds, which compose with
.ago()/.from_now() to produce wall-clock Time values; see the
Time guide.
General numeric helpers remain plain functions:
abs(x)/min(a, b)/max(a, b)/mod(a, b), all accepting
Int | Float (mixed Int/Float arguments auto-promote, same as
arithmetic). abs inherits negation's overflow behavior, so abs of the
most negative 64-bit Int promotes to an arbitrary-precision Int
instead of raising or silently wrapping — there is no longer a most
negative Int that abs can't represent. mod(a, b) is the C-style
remainder, the counterpart of truncating division: unlike % (and /,
which both floor), its result keeps the dividend's sign for both types
— mod(-7, 3) is -1, not 2 (not Euclidean/
Python-style mod), and mod(-7.0, 3.0) is likewise -1.0. b == 0
raises the same ZeroDivisionError integer division would; b == 0.0
raises a rescuable RangeError instead (from to_i rejecting the
resulting Infinity/NaN quotient — Float division by zero itself
never raises, only the truncation step does).
sqrt(x), sin(x), cos(x), tan(x), exp(x), log(x), tanh(x), and
pow(base, exponent) are native functions (no bytecode primitive to build
on, same reasoning as chr/to_f/to_i) accepting Int | Float for every
argument and always returning Float — pow(2, 10) is 1024.0, not
1024, even though both arguments are Int. No extra validation: results
follow IEEE-754 directly, so sqrt(-1.0) is NaN rather than an error, the
same philosophy Float arithmetic already uses throughout.
Tensor is a dense, row-major Float matrix — Tensor.zeros(rows, cols),
Tensor.from_array(nested_array) (an Array of same-length Arrays of
Int/Float), or Tensor.random(rows, cols, seed) (deterministic
pseudorandom values in [-1, 1) from a plain LCG, filled directly in C —
orders of magnitude faster than building the same values through
Tensor.from_array and a d6-level loop, which matters once "rows x
cols" reaches real model-weight sizes). Instance methods: #rows(),
#cols(), #get(row, col), #set(row, col, value) (IndexError out of
bounds, matching Array#[]), #matmul(other) (threaded, k-blocked; shape
mismatch raises TypeError), #transpose() (a fresh copy), and
#to_a() (back to an ordinary nested Array). Also a set of in-place
elementwise/shape mutators and their backward passes, added once a real
training loop (examples/transformer) showed per-element #get/#set
round-trips, not #matmul, were the actual bottleneck at that example's
own training-sized shapes: #add!(other)/#scale!(scalar)/
#add_bias!(bias)/#row_softmax!()/#gelu!() (forward mutators, each
modifying the receiver in place and returning it), #column_sums()/
#columns(start, count)/#add_columns!(start, other)/#clone() (shape
helpers), and #softmax_backward(grad_output)/#gelu_backward(grad_output)
(called on the original input)/#layernorm_forward(gamma, beta, eps)
(returns [output, normalized, inv_std], an Array[Tensor])/
#layernorm_backward(gamma, grad_output, inv_std) (called on
layernorm_forward's own normalized result; returns [grad_x, grad_gamma, grad_beta]) — see examples/transformer/lib/tensor_ops.d6
for friendlier d6 wrappers around these, and that example's own
gradcheck.d6 for every formula checked against numerical
(finite-difference) gradients. Still deliberately
narrow, not a general tensor library: no broadcasting, no non-2D shapes,
and no autodiff built into Tensor itself — examples/transformer's own
reverse-mode autograd (examples/transformer/lib/var.d6 and autograd.d6) is ordinary
d6 code layered on top of these primitives, not a VM feature. It
exists to measure real matmul/elementwise throughput (bench/-style
native code, not boxed DiamondValue arrays) before committing to a
fuller API surface — see examples/transformer/README.md's own "Scope"
section for what a real training workload still needs that this doesn't
provide (batching, a reshape/view story, anything beyond plain SGD).
Time.monotonic() returns a Float number of seconds from
CLOCK_MONOTONIC — a duration-only clock: the value itself means
nothing (not a calendar timestamp, not comparable across processes),
only the difference between two readings does, e.g. elapsed = Time.monotonic() - start for timing a request in a rack middleware.
It is intentionally separate from d6's wall-clock/calendar Time values;
see the Time guide for those APIs.
BCrypt.hash(password: String, cost: Int) and BCrypt.verify(password: String, digest: String) -> Bool are native bcrypt password hashing,
backed by this system's own libxcrypt (crypt_gensalt_rn/crypt_r,
real $2b$ bcrypt) rather than a vendored implementation — the same
"link a system library" pattern every other native dependency here
already follows (sqlite3, libpq, mariadb, OpenSSL). .hash always
takes both arguments explicitly (no default cost at this layer — see
packages/active_record/README.md's #secure_password= for where a
default of 12 actually lives); cost outside 4..31 raises
ArgumentError before any hashing happens. .verify re-derives a digest
from password using digest itself as the salt/settings source and
compares with a constant-time comparison — a malformed or foreign
digest (not a real bcrypt hash) is an ordinary false, not an
exception, since checking a password against a bad hash is a normal
outcome here, not a programmer error:
digest = BCrypt.hash("hunter2", 12) # => "$2b$12$..."
BCrypt.verify("hunter2", digest) # => true
BCrypt.verify("wrong", digest) # => false
libxcrypt's bcrypt support is a Linux-specific fact about this system's
crypt(3), not something d6 papers over — see docs/roadmap.md's
"Explicitly deferred" section on multi-platform portability. A password
containing an embedded NUL byte is truncated at that point before
hashing, the same inherent limitation every C-crypt-backed bcrypt
binding has (password is passed to crypt_r as a NUL-terminated C
string).
SecureRandom.bytes(n: Int) -> String returns n cryptographically
random bytes (OpenSSL RAND_bytes, already linked for TLS) as a raw
d6 String — d6 strings are already raw byte buffers, so no
separate binary type is needed. SecureRandom.hex(n: Int) -> String
returns the same n random bytes hex-encoded, as a 2*n-character
String. Both raise ArgumentError for a negative n (or one large
enough to overflow the underlying int-sized call into OpenSSL).
Suited to session/remember-me/password-reset tokens and similar —
SecureRandom.hex(32) for a 256-bit token as a 64-character hex string.
SemVer compares and checks version strings, with the same grammar and precedence
facet resolves with. A version may start with v, as a git tag often does.
SemVer.valid?(text: String) -> BoolSemVer.compare(a: String, b: String) -> Intis -1, 0 or 1. Numeric prerelease identifiers compare as numbers, a prerelease sorts before its release, and build metadata is ignored.SemVer.satisfies?(version: String, range: String) -> Booltakes the rangesCutfiledoes (^1.2.0,~1.2.0,>=1.0.0 <2.0.0). On 0.x a caret range doesn't cross a minor.
compare and satisfies? raise ArgumentError for a malformed version or range.
["1.10.0", "1.2.0", "1.2.0-rc.1"].sort() do |a, b| SemVer.compare(a, b) end
# ["1.2.0-rc.1", "1.2.0", "1.10.0"]
SemVer.satisfies?("0.4.1", "^0.4.0") # true
Digest.sha256(data: String) -> String returns the lowercase hexadecimal
SHA-256 digest of the string's raw bytes. HMAC.sha256(key: String, data: String) -> String returns the corresponding keyed HMAC, also as 64 lowercase
hexadecimal characters. Both preserve embedded NUL bytes and are backed by
OpenSSL's libcrypto:
Digest.sha256("abc")
HMAC.sha256("secret", "payload")
HMAC.verify(data: String, key: String, signature: String) -> Bool
recomputes HMAC.sha256(key, data) and compares it against signature
with a constant-time comparison (CRYPTO_memcmp, the same primitive
BCrypt.verify already uses) rather than a plain == on the two hex
strings — verifying a signature is exactly the kind of comparison a
timing side-channel matters for, unlike an ordinary string equality
check. A signature of the wrong length, or not really hex at all, is
an ordinary false, not an exception, matching BCrypt.verify's own
"a bad input here is a normal outcome" reasoning:
signature = HMAC.sha256("secret", "payload")
HMAC.verify("payload", "secret", signature) # => true
HMAC.verify("payload", "secret", "forged") # => false
Digest.sha1(data: String) -> String and HMAC.sha1(key: String, data: String) -> String are the same pair, using SHA-1 instead (40 lowercase
hexadecimal characters). SHA-1 is cryptographically weak for new signing
use — this exists because TOTP (RFC 6238) mandates HMAC-SHA1 by spec, not
as a second general-purpose recommendation alongside sha256. HMAC.verify
has no sha1 counterpart; it only ever checks against HMAC.sha256.
Cipher.encrypt(key: String, plaintext: String) -> String and
Cipher.decrypt(key: String, blob: String) -> String | Nil are native
AES-256-GCM authenticated encryption, backed by OpenSSL's EVP_CIPHER
API (already linked for TLS) rather than a vendored implementation.
key must be exactly 32 raw bytes (SecureRandom.bytes(32)) —
ArgumentError otherwise, since AES-256 has no meaningful way to
silently accept a wrong-length key. .encrypt generates a fresh random
12-byte nonce every call and returns one binary-safe String: the
nonce, the 16-byte GCM authentication tag, then the ciphertext, all
concatenated — .decrypt needs nothing but the key and this one blob.
.decrypt returns nil, not an exception, for a wrong key, a tampered
or truncated blob, or anything else that fails GCM's own built-in tag
check — a forged or expired encrypted cookie is expected to happen in
normal operation, the same "not a programmer error" reasoning
BCrypt.verify/HMAC.verify already use, and there is no separate
signature step to add: the tag check inside EVP_DecryptFinal_ex
already provides both confidentiality and tamper-evidence in one pass,
with no comparison of d6's own to expose a timing side-channel.
key = SecureRandom.bytes(32)
blob = Cipher.encrypt(key, "sensitive session data")
Cipher.decrypt(key, blob) # => "sensitive session data"
Cipher.decrypt(SecureRandom.bytes(32), blob) # => nil (wrong key)
Gzip.compress(data: String) -> String and Gzip.decompress(data: String, max_size: Int) -> String are gzip-wrapped deflate, backed by the
already-linked zlib. .decompress also accepts a plain zlib-wrapped
stream (auto-detected) — some servers send Content-Encoding: deflate
as one of these rather than raw deflate, and this covers both real-world
spellings without the caller needing to know which. max_size bounds
the decompressed output, checked as it grows rather than after the
fact — decompressing a small, untrusted input into an unbounded output
("zip bomb") is a real risk for anything that automatically decompresses
a network response, so this raises a rescuable IOError instead of ever
fully materializing an over-cap buffer. Malformed input (corrupt or
truncated) also raises IOError, not nil — unlike Cipher.decrypt,
there's no expected-in-normal-operation forged-input case here to justify
that softer failure mode.
compressed = Gzip.compress("some text")
Gzip.decompress(compressed, 10 * 1024 * 1024) # => "some text"
Base64.encode(data: String) -> String and Base64.decode(data: String) -> String are the standard (RFC 4648) alphabet with padding, via
OpenSSL's EVP_EncodeBlock/EVP_DecodeBlock (already linked). Needed
for HTTP Basic auth (Authorization: Basic <base64>). .decode validates
the character set itself before decoding (rather than trusting
EVP_DecodeBlock's own lenience, which varies by OpenSSL version), so a
malformed input (wrong length, an invalid character, misplaced =
padding) always raises a rescuable ArgumentError with a clear message:
Base64.encode("hello world") # => "aGVsbG8gd29ybGQ="
Base64.decode("aGVsbG8gd29ybGQ=") # => "hello world"
See packages/cookies for signed and encrypted cookie helpers built on
HMAC.verify/Cipher above.
array_sort(values: Array[Int]) returns a new sorted array (input
untouched); Int is the only type with a native ordering comparison,
so this is Int-only, checked up front (expected Array[Int], got Array on a non-Int element) rather than failing confusingly mid-sort.
StringBuilder (defined in the prelude, lib/core.d6) is the named
escape hatch from result = result + piece in a loop — quadratic,
since it reallocates and copies the whole accumulated string on every
iteration:
sb = StringBuilder.new()
sb.append("hello")
sb.append(", world")
sb.to_s() # => "hello, world"
"#{sb}" # same, via to_s
sb.length() # => 12
#append returns self, so calls chain: sb.append("a").append("b").
It's a plain d6 class, not a native object — #append pushes
"#{piece}" onto an internal Array (already O(1) amortized) and
#to_s calls the native .join("") above once, so the total cost of
building a string this way is O(n), the same complexity Array#join
already has for a pre-collected array of pieces.
Prelude methods
Beyond the methods above, built-in values answer most of the everyday
Enumerable-style methods. They're ordinary d6 functions in the prelude
(lib/core.d6, named diamond_array_…, diamond_hash_…, and so on) that
the VM finds when a value has no native method of that name. A program can
define its own array_foo(values, ...) (or hash_, string_, integer_,
float_, numeric_) function to add a method, or to replace one of these.
- Array:
size,insert(i, x),unshift(x),shift,clear,replace(other),delete(x),delete_if,keep_if,fill(x),first(n),last(n),count()/count(x)/count { },find_index/index/rindex(a value or a block),values_at,dig,to_h,transpose,minmax,sum { },filter_map,each_with_object,none?,one?(both also blockless),inject,product,combination(k),chunk_while,slice_when,intersect?(other),zipwith up to six arrays,rotate,shuffle,sample,to_a. Operators:a + b,a - b(elements not inb),a & b,a | b,a * n,a * ", "(join), and a negative index (a[-1]) ora[start, length]. - Hash: a block that takes two parameters gets
(key, value), as in Ruby; a one-parameter block still gets the value, as Hash iteration always has.map,select/filterandreject(aHashfor a two-parameter block),any?,all?,none?,count,find,sum,min_by,max_by,sort_by,sort,group_by,partition,filter_map,each_with_object(a three-parameter block gets(key, value, memo)),each_with_index,first/first(n)([key, value]pairs),values_at/fetch_values(raisingIndexErrorfor a missing key),to_a,size,key?/has_key?/include?/member?,value?,dig,transform_values,transform_keys,invert,update,slice,except,compact,delete_if/keep_if(in place, like Array's), andfetch(key)without a fallback (anIndexErrorfor a missing key).any?,all?andnone?without a block ask about the entries. - String:
size,bytesize,index,rindex,count(chars),delete(chars),squeeze/squeeze(chars),index(needle, start),swapcase,lines,each_line,each_char,each_byte,center,chop,partition,delete_prefix,delete_suffix,casecmp?,between?,match?(regexp),to_sym,hex,oct,to_i(base),getbyte(i),split()(on whitespace),split(separator, limit),insert(index, other)andprepend(other)(new Strings, like every String method), andljust/rjustwith a default space pad. - Int and Float:
**(exact for anIntbase and a non-negativeIntexponent, aFloatotherwise),pow,clamp,between?,divmod,fdiv,zero?,positive?,negative?,even?,odd?,succ,pred,gcd,lcm,digits,to_s(base), and on Floattruncate,nan?,infinite?,finite?, andfloor(digits)/ceil(digits)/round(digits); on Int,step(limit, by) do |i| ... end(downward for a negativeby). - Every value:
to_s()(the text interpolation gives it) andnil?();Callable#arity. - Math:
Math.sqrt,cbrt,sin,cos,tan,exp,log,log2,log10,tanh,hypot,pi,e. The same builtins are also plain functions (sqrt(x)). - Range:
step(n),first(n),reverse_each.
values_at, fetch_values, dig, slice, and except take up to six
arguments, and zip up to six arrays.