Struct for Ruby
Full-parity Ruby port of the canonical TypeScript implementation.
For motivation, language-neutral concepts, and the cross-language parity matrix, see the top-level README and REPORT.md.
Install
In the monorepo:
cd ruby
bundle install
The library is a single file: voxgig_struct.rb.
Module: VoxgigStruct.
require_relative 'voxgig_struct'
Quick start
require_relative 'voxgig_struct'
store = {
'db' => { 'host' => 'localhost' },
'user' => { 'first' => 'Ada', 'last' => 'Lovelace' },
'age' => 36,
}
puts VoxgigStruct.getpath(store, 'db.host')
# localhost
puts VoxgigStruct.transform(store, {
'name' => '`user.first`',
'surname' => '`user.last`',
'years' => '`age`',
}).inspect
# {"name"=>"Ada", "surname"=>"Lovelace", "years"=>36}
VoxgigStruct.validate(store, {
'user' => {
'first' => '`$STRING`',
'last' => '`$STRING`',
},
'age' => '`$INTEGER`',
})
Function reference
Source: voxgig_struct.rb. Module
VoxgigStruct.
Predicates
VoxgigStruct.isnode(val) # bool — map or list
VoxgigStruct.ismap(val) # bool — Hash
VoxgigStruct.islist(val) # bool — Array
VoxgigStruct.iskey(key) # bool — non-empty String or Integer
VoxgigStruct.isempty(val) # bool
VoxgigStruct.isfunc(val) # bool — Proc/lambda
VoxgigStruct.isnode({'a' => 1}) # true
VoxgigStruct.isnode([1]) # true
VoxgigStruct.ismap({'a' => 1}) # true
VoxgigStruct.ismap([]) # false
VoxgigStruct.islist([1, 2]) # true
VoxgigStruct.iskey('name') # true
VoxgigStruct.isempty([]) # true
VoxgigStruct.isempty(nil) # true
VoxgigStruct.isfunc(->(x) { x }) # true
Type inspection
VoxgigStruct.typify(value) -> Integer # bit-field
VoxgigStruct.typename(t) -> String # human name
VoxgigStruct.typify(1) # T_scalar | T_number | T_integer (201326720)
VoxgigStruct.typify(42) # T_scalar | T_number | T_integer
VoxgigStruct.typify('hi') # T_scalar | T_string
VoxgigStruct.typify(nil) # T_scalar | T_null
VoxgigStruct.typename(8192) # 'map' (8192 == T_map)
VoxgigStruct.typename(VoxgigStruct.typify('hi')) # 'string'
Size, slice, pad
VoxgigStruct.size(val) -> Integer
VoxgigStruct.slice(val, start = nil, finish = nil, mutate = false)
VoxgigStruct.pad(str, padding = nil, padchar = nil) -> String
VoxgigStruct.size([1, 2, 3]) # 3
slice keeps the first N; a negative start drops the last |start|
items, and finish is exclusive:
VoxgigStruct.slice([1, 2, 3, 4, 5], 1, 4) # [2, 3, 4]
VoxgigStruct.slice('abcdef', -3) # 'abc' (drops the last 3)
VoxgigStruct.pad('a', 3) # 'a '
VoxgigStruct.pad('hi', 5) # 'hi '
VoxgigStruct.pad('hi', -5, '*') # '***hi'
Property access
VoxgigStruct.getprop(val, key, alt = UNDEF)
VoxgigStruct.setprop(parent, key, val)
VoxgigStruct.delprop(parent, key)
VoxgigStruct.getelem(val, key, alt = UNDEF)
VoxgigStruct.getdef(val, alt)
VoxgigStruct.haskey(val, key) -> bool
VoxgigStruct.keysof(val) -> Array
VoxgigStruct.items(val) -> Array
VoxgigStruct.strkey(key) -> String
VoxgigStruct.getprop({'x' => 1}, 'x') # 1
VoxgigStruct.getprop({}, 'b', 'fallback') # 'fallback'
VoxgigStruct.setprop({'a' => 1}, 'b', 2) # {'a'=>1, 'b'=>2}
VoxgigStruct.delprop({'a' => 1, 'b' => 2}, 'a') # {'b'=>2}
VoxgigStruct.getelem([10, 20, 30], -1) # 30
VoxgigStruct.haskey({'a' => 1}, 'a') # true
VoxgigStruct.items({'a' => 1, 'b' => 2}) # [['a', 1], ['b', 2]]
VoxgigStruct.strkey(2.2) # '2'
VoxgigStruct.strkey(1) # '1'
VoxgigStruct.strkey('foo') # 'foo'
VoxgigStruct.keysof({'b' => 4, 'a' => 5}) # ['a', 'b'] (sorted)
Path operations
VoxgigStruct.getpath(store, path, injdef = nil)
VoxgigStruct.setpath(store, path, val, injdef = nil)
VoxgigStruct.pathify(val, startin = nil, endin = nil) -> String
VoxgigStruct.getpath({'a' => {'b' => {'c' => 42}}}, 'a.b.c') # 42
VoxgigStruct.getpath({'a' => [10, 20]}, 'a.1') # 20
store = {}
VoxgigStruct.setpath(store, 'db.host', 'localhost')
# store == {'db' => {'host' => 'localhost'}}
VoxgigStruct.setpath({'a' => 1, 'b' => 2}, 'b', 22) # {'a'=>1, 'b'=>22}
VoxgigStruct.pathify(['a', 'b', 'c']) # 'a.b.c'
Tree operations
VoxgigStruct.walk(val, before = nil, after = nil, maxdepth = nil)
VoxgigStruct.merge(val, maxdepth = nil)
VoxgigStruct.clone(val)
VoxgigStruct.flatten(list, depth = nil)
VoxgigStruct.filter(val, check)
after = ->(key, val, parent, path) { val.nil? ? 'DEFAULT' : val }
VoxgigStruct.walk(tree, nil, after)
Last input wins; maps deep-merge; lists merge by index:
VoxgigStruct.merge([
{ 'a' => 1, 'b' => 2, 'k' => [10, 20], 'x' => { 'y' => 5, 'z' => 6 } },
{ 'b' => 3, 'd' => 4, 'e' => 8, 'k' => [11], 'x' => { 'y' => 7 } },
])
# { 'a' => 1, 'b' => 3, 'd' => 4, 'e' => 8, 'k' => [11, 20], 'x' => { 'y' => 7, 'z' => 6 } }
VoxgigStruct.clone({ 'a' => { 'b' => [1, 2] } }) # { 'a' => { 'b' => [1, 2] } } (a deep copy)
VoxgigStruct.flatten([1, [2, [3]]]) # [1, 2, [3]] (one level by default)
VoxgigStruct.flatten([1, [2, [3, [4]]]]) # [1, 2, [3, [4]]]
filter passes each [key, value] pair to the check and returns the
matching values (not the pairs):
VoxgigStruct.filter([1, 2, 3, 4, 5], ->(kv) { kv[1] > 3 })
# [4, 5]
String / URL / JSON
VoxgigStruct.escre(s) -> String
VoxgigStruct.escurl(s) -> String
VoxgigStruct.join(arr, sep = nil, url = nil) -> String
VoxgigStruct.joinurl(parts) -> String
VoxgigStruct.jsonify(val, flags = nil) -> String
VoxgigStruct.stringify(val, maxlen = nil, pretty = nil) -> String
VoxgigStruct.replace(s, from, to) -> String
VoxgigStruct.escre('a.b+c') # 'a\\.b\\+c'
VoxgigStruct.escurl('hello world?') # 'hello%20world%3F'
VoxgigStruct.join(['a', 'b', 'c'], '/') # 'a/b/c'
jsonify pretty-prints by default (indent 2); pass { 'indent' => 0 } for
the compact form:
VoxgigStruct.jsonify({'a' => 1})
# {
# "a": 1
# }
VoxgigStruct.jsonify({'a' => 1, 'b' => 2}, { 'indent' => 0 }) # '{"a":1,"b":2}'
stringify is the compact, quote-light form — keys are sorted and object
braces are kept; the second argument caps the length (the ... counts):
VoxgigStruct.stringify({'a' => 1, 'b' => [2, 3]}) # '{a:1,b:[2,3]}'
VoxgigStruct.stringify('verylongstring', 5) # 've...'
Inject / transform / validate / select
VoxgigStruct.inject(val, store, injdef = nil)
VoxgigStruct.transform(data, spec, injdef = nil)
VoxgigStruct.validate(data, spec, injdef = nil)
VoxgigStruct.select(children, query) -> Array
# Backtick refs in strings are replaced by store values.
VoxgigStruct.inject({ 'x' => '`a`', 'y' => 2 }, { 'a' => 1 }) # { 'x' => 1, 'y' => 2 }
VoxgigStruct.inject(
{ 'greeting' => 'hello `name`' },
{ 'name' => 'Ada' }
)
# { 'greeting' => 'hello Ada' }
VoxgigStruct.transform(
{ 'hold' => { 'x' => 1 }, 'top' => 99 },
{ 'a' => '`hold.x`', 'b' => '`top`' }
)
# { 'a' => 1, 'b' => 99 }
Transform commands drive structural ops. A command like $EACH appears in
value position — as the first element of a list
['$EACH', path, subspec] — mapping the sub-spec over every entry at
path:
VoxgigStruct.transform(
{ 'v' => 1, 'a' => [{ 'q' => 13 }, { 'q' => 23 }] },
{ 'x' => { 'y' => ['`$EACH`', 'a', { 'q' => '`$COPY`', 'r' => '`.q`', 'p' => '`...v`' }] } }
)
# { 'x' => { 'y' => [{ 'q' => 13, 'r' => 13, 'p' => 1 }, { 'q' => 23, 'r' => 23, 'p' => 1 }] } }
Putting a command in key position (or, for $APPLY, directly under a
map) is an error — commands must be list values:
VoxgigStruct.transform({}, { 'x' => '`$APPLY`' })
# raises: $APPLY: invalid placement in parent map, expected: list.
# Validate against a shape (raises on mismatch).
VoxgigStruct.validate(
{ 'name' => 'Ada', 'age' => 36 },
{ 'name' => '`$STRING`', 'age' => '`$INTEGER`' }
)
# { 'name' => 'Ada', 'age' => 36 }
# Find children matching a query.
VoxgigStruct.select(
{ 'a' => { 'name' => 'Alice', 'age' => 30 }, 'b' => { 'name' => 'Bob', 'age' => 25 } },
{ 'age' => 30 }
)
# [{ 'name' => 'Alice', 'age' => 30, '$KEY' => 'a' }]
Builders
VoxgigStruct.jm(*kv) -> Hash
VoxgigStruct.jt(*v) -> Array
VoxgigStruct.jm('a', 1, 'b', 2) # {'a' => 1, 'b' => 2}
VoxgigStruct.jt(1, 2, 3) # [1, 2, 3]
Injection class
Full implementation with descend, child, setval instance
methods. Used internally by inject/transform/validate; you
need it when writing custom injectors.
Injection helpers
VoxgigStruct.checkPlacement(modes, ijname, parentTypes, inj)
VoxgigStruct.injectorArgs(argTypes, args)
VoxgigStruct.injectChild(child, store, inj)
Select operators
The Ruby select supports compound query operators:
AND OR NOT CMP
See voxgig_struct.rb for full operator
semantics.
Constants
Sentinels
VoxgigStruct::SKIP
VoxgigStruct::DELETE
VoxgigStruct::UNDEF # frozen sentinel object for "absent"
Type bit-flags
VoxgigStruct::T_any VoxgigStruct::T_noval VoxgigStruct::T_boolean
VoxgigStruct::T_decimal VoxgigStruct::T_integer VoxgigStruct::T_number
VoxgigStruct::T_string VoxgigStruct::T_function VoxgigStruct::T_symbol
VoxgigStruct::T_null VoxgigStruct::T_list VoxgigStruct::T_map
VoxgigStruct::T_instance VoxgigStruct::T_scalar VoxgigStruct::T_node
Walk / inject phase flags
VoxgigStruct::M_KEYPRE
VoxgigStruct::M_KEYPOST
VoxgigStruct::M_VAL
VoxgigStruct::MODENAME
Transform commands
$DELETE $COPY $KEY $META $ANNO
$MERGE $EACH $PACK $REF $FORMAT $APPLY
Validate checkers
$MAP $LIST $STRING $NUMBER $INTEGER $DECIMAL $BOOLEAN
$NULL $NIL $FUNCTION $INSTANCE $ANY $CHILD $ONE $EXACT
Notes
UNDEF, nil, and JSON null
Ruby has nil. The port distinguishes:
nil— JSON null (a defined scalar).VoxgigStruct::UNDEF— frozen sentinel for "absent".
typify(nil) returns T_scalar | T_null; typify(UNDEF) returns
T_noval.
Method naming
Ruby method names match canonical lowercase (getpath, setpath,
getprop), not Ruby's idiomatic snake_case. Parity beats style.
Walk-based merge
merge is implemented as a walk with before/after callbacks
and a maxdepth parameter, matching the canonical algorithm.
Test status
81 runs, 159 assertions, 0 failures.
Regex
Uniform six-function regex API (see /design/REGEX_API.md). The Ruby port
wraps the built-in Regexp (Onigmo engine).
API
| Function | Maps to |
|---|---|
re_compile(pattern) |
Regexp.new(pattern) |
re_test(pattern, input) |
input =~ re |
re_find(pattern, input) |
input.match(re) → [whole, group1, ...] |
re_find_all(pattern, input) |
input.scan(re) (one row per match) |
re_replace(pattern, input, repl) |
input.gsub(re, repl) |
re_escape(s) |
Regexp.escape(s) |
Dialect
Patterns must stay inside the RE2 subset documented in /design/REGEX.md.
Onigmo supports backreferences and lookaround; using them will not be
portable to the Go / Rust / C / Lua / Zig ports.
Sharp edges
- Catastrophic backtracking. Onigmo has internal mitigations for
some classic ReDoS shapes —
^(a+)+$against 22 a's plus!runs in microseconds here. Larger inputs or different shapes can still blow up; the safe rule is to stay inside the RE2 subset and avoid nested quantifiers. - Zero-width
replace.re_replace("a*", "abc", "X")returns"XXbXcX"— the ECMA convention shared by all PCRE/ECMA/.NET/Java/Onigmo engines plus the in-tree Thompson ports. Go (RE2) returns"XbXcX"instead; see/design/REGEX_PATHOLOGICAL.md.
See /design/REGEX_PATHOLOGICAL.md for the cross-port pathological-input panel.
Build and test
cd ruby
bundle install
make test
Tests in test_voxgig_struct.rb consume
fixtures from ../build/test/.