Spinel is a whole-program ahead-of-time compiler: it reads the entire program, infers a static type for every value, emits C, compiles it, and runs the binary. There is no Ruby interpreter, parser, or type-inference engine in the running program — it is just C. That model is what buys the speedup, and it is also the source of every limitation below.
This document is the honest catalogue. It is organized by kind of limit:
- Fundamental — incompatible with whole-program AOT; will not change without abandoning the model (e.g. bundling an interpreter).
- Partial / relaxable — genuinely limited today, but additively fixable.
- By design — a deliberate, documented choice; the intentional CRuby deviations are catalogued under By design below.
- Now supported — things that are not limits (corrects older write-ups that described an earlier version of the compiler).
These need a runtime parser, a runtime metaobject protocol, an allocation registry, or stack reification — none of which exist in a flat compiled binary.
| Feature | Behaviour | Why it's fundamental |
|---|---|---|
eval / instance_eval("str") / class_eval("str") |
unsupported | needs a runtime parser + type system. (Block forms — instance_eval { } — DO work; the block is compiled.) |
method_missing |
not dispatched (defining it warns at compile time) | every call site is a direct C call; an undefined-method call can't fall back to a per-receiver hook. The method is still callable explicitly. |
define_method with a runtime-computed name/body |
only literal names work | a runtime-built method has no compiled body |
ObjectSpace (each_object, count_objects) |
unsupported | no class-keyed allocation registry; the GC tracks bytes, not a live-object index |
TracePoint / set_trace_func |
unsupported | require an interpreter loop to hook |
binding as an object |
unsupported | reifying the local scope needs a runtime name->slot table; locals are C stack slots. binding.local_variable_get(:x) with a literal name is supported -- it resolves to the known slot at compile time |
Refinements (refine / using) |
no-op / unresolved | scope-keyed dispatch is incompatible with direct C calls |
callcc / Continuation |
unsupported | multi-shot full-stack capture has no flat-C analogue |
Class.new(parent) { ... } (runtime class) |
unsupported | the class graph is baked at compile time |
Singleton methods (def obj.m, class << obj; def m; end; end, obj.define_singleton_method(:m) { }, obj.extend(Mod)) on a receiver whose creation site is not visible |
unsupported | these DO work when the receiver is a constant or a local whose only write is <UserClass>.new(...): the object gets a synthesized anonymous subclass carrying the methods, which is the AOT form of CRuby's hidden singleton class. What is left out is a receiver spinel cannot trace to one .new (a factory return, a loop, a conditional), and one whose class has no subclassable layout: Object.new / BasicObject, a builtin (String, Array), a Struct or Data, an exception. Those report the method as undefined |
Object#singleton_class as an OBJECT (and Class#attached_object) |
unsupported | the singleton class above is synthesized, not reified: there is no runtime class object to hand back. class << obj as a definition form works -- see the row above |
Runtime structural mutation of a class through an explicit receiver (Klass.include(M), Klass.attr_accessor(...), Klass.define_method(...) outside the class body) |
unsupported | the class graph, ancestor chain, and method/ivar layout are baked at compile time; the same declarations inside a class body work |
General reflection (methods, instance_variables) and instance_variable_get/set with a non-literal name |
unsupported | ivars are C struct offsets with no name→offset table; DCE strips method names. A literal instance_variable_get(:@x) / instance_variable_set(:@x, v) is supported — it resolves to the known struct offset, like send(:literal) below. |
User-defined #hash / #eql? for hash keys |
not dispatched (identity probe) | the hash machinery can't call back into a user method per key |
A method that uses its block (yield or block.call) and recurses into itself (def rec(n, &b); ...; rec(n-1, &b); yield n; end) |
compile error (loud, was a hang / undefined-symbol) | a block-using method is inlined at each call site (there is no standalone function that takes the block), so a self-call inlines its own body unboundedly — the runtime base case is invisible at compile time. Recursion through a yielded block (with_state { with_state { } }, finite source nesting) does work |
Monitor#class |
reports Thread::Mutex |
a Monitor IS a mutex here, with reentrancy switched on per object, and the class name for a TY_MUTEX value is decided at compile time from the type rather than read off the object. #synchronize (including reentrant use), #try_enter and mutual exclusion across threads all behave as CRuby's do; only the name differs. Monitor#new_cond / the MonitorMixin module are not modelled. |
require of stdlib .rb that leans on metaprogramming / C extensions (e.g. json/pure, the require "time" parsing extensions like Time.parse / Time.strptime) |
unsupported | such stdlib code runs off the AOT path. A require is resolved at parse time by splicing a bundled lib/X.rb; the libraries that ship this way — set, forwardable, optparse, erb, csv, pathname, stringio, strscan — do work. The built-in Time class (Time.now / at / local / utc, plus strftime / zone) works without any require; only the require "time" string-parsing additions are missing. |
The require-gated stdlib Spinel does provide (StringIO, IO#winsize,
Time#iso8601, ...) requires its require, matching CRuby; an unsatisfiable
require is a compile error. This is opt-in today via SPINEL_REQUIRE_GATE=1.
See require.md for which stdlib needs which require.
| Mixed / non-UTF-8 encodings | UTF-8 / ASCII-8BIT only | one internal representation; transcoding tables are out of scope |
| Embedded NUL in general binary strings | char * boundary assumption | most string ops are NUL-terminated at the C boundary |
send/public_send/__send__ with a non-literal name (send(meth)) is
partially supported: an explicit-receiver send lowers to a static dispatch over
the method names that appear as symbol/string literals anywhere in the
program — recv.send(name) → name == :a ? recv.a : name == :b ? recv.b : … : raise NoMethodError — with the receiver's type and the argument count selecting
which arms resolve (the result is poly). A name that is not one of those
literals, or not a method on the receiver, raises NoMethodError at runtime. A
name drawn from outside the program's closed set of literals still can't be
dispatched. A literal name is fully resolved — see below.
Limited today, but additively fixable; listed roughly easiest-first.
| Feature | Today | Path to relax |
|---|---|---|
Exception#backtrace / Kernel#caller |
return [] (class + message work) |
populate frames from a compile-time call-site→source side-table (the --line-map map already exists) |
Thread real parallelism |
implemented as a true M:N runtime (no GVL): N OS workers (min(online cores, SPINEL_WORKERS)) run green threads in parallel over a stop-the-world GC, with real Mutex/Queue/SizedQueue/ConditionVariable. A monitor thread timeslices CPU-bound threads (~10ms quantum) so a thread looping without yielding cannot starve its siblings (it signals the worker with SIGURG, overridable via SPINEL_PREEMPT_SIGNAL). The single-threaded archive is unchanged (a non-Thread program is byte-identical) |
the N workers run per-worker run queues with work stealing, and Kernel#sleep and blocking I/O are scheduler-aware (a sleeping / I/O-blocked thread frees its OS worker). preemption is taken at safepoint polls (loop back-edges), so a thread spending a long time inside a single runtime call with no poll yields only when that call returns; concurrent allocation is thread-safe (heap-lock-protected allocators, atomic heap byte counters, per-worker object pools) but every allocation still crosses one global heap lock; remaining work: fully async (signal-interrupted) preemption of such regions, and per-worker allocation buffers (TLAB) to make allocation-heavy parallel code scale. See docs/thread.md |
Marshal of user objects with container-typed ivars |
primitives + Array + Hash + Bignum + Complex + Rational + plain user objects work, including cyclic and shared references (Marshal.dump/load, CRuby 4.8 wire format, byte-compatible for the supported subset); an object whose ivar is a statically typed Array/Hash (not a poly ivar) is not yet dumpable |
a user object dumps/loads through a compile-time-generated per-class dispatcher. Supported ivar types: scalars (Integer/Float/String/true/false/Symbol/Bignum), poly (mixed) ivars, and nested user objects. A typed-container ivar would mismatch the loader's always-poly containers, so such a class raises TypeError on dump; value-type and Exception-subclass objects are also out of scope. Complex's components are float-only, so they round-trip as Floats |
Mixin/inheritance lifecycle hooks (included / inherited / extended) |
defined but not fired | emit a startup call with the literal class arg (the include/inherit graph is known at compile time) |
External Enumerator — .each with no block is only an Enumerator on Array / Range, not on an arbitrary user method |
mostly supported | Array#each / Range#each with no block return a working external Enumerator (#next / #peek / #rewind / #size, loop stops on StopIteration). Enumerator.new { |y| ... } is a fiber-backed generator (y << v, y.yield(v), and the bare y.yield v without parentheses, plus #next / #peek / #rewind / #take / #first, infinite generators work). Enumerator::Lazy over an int range (incl. endless) or int array fuses map/select/reject/filter/take_while chains terminated by first(n) / to_a / force. Chained block→.to_a forms (each_slice(n).to_a, filter_map, map{}.to_a) also work. |
Enumerable#each_entry on a user class whose #each yields MULTIPLE values |
yields them spread, as #each does, rather than packed into an array |
on every builtin enumerable (Array/Hash/Range/Enumerator/Dir) #each yields one value per element, so each_entry is compiled as each and matches CRuby exactly. The difference only shows for a user #each that does yield a, b, where CRuby's each_entry hands the block [a, b]. Packing needs the yield arity of the user's #each, which is a static property of its body |
StringIO#each_line / #each / #each_char / #each_byte with NO block |
LocalJumpError, where CRuby answers an Enumerator |
the block forms are exact. Answering an Enumerator instead would make the method return either that or self, a union with no C slot. io.readlines.each, io.read.each_char, and io.read.bytes say the same thing and do have one |
Array#hash (and arrays as hash keys) |
unsupported | a builtin is additive, but array keys need the fundamental key-dispatch above |
| Sockets | TCP / UDP / UNIX-domain, as IO handles — see below | additive: each missing class and method is its own runtime binding |
A promoted value stored into an int-typed Array (--int-overflow=promote) |
truncated back to int64 by the store | the array's element type has to widen with the value; blanket-widening every int array costs promote mode more than it buys, so this wants a data-flow rule. Seeding the array with one value past 2^63 (or holding the state in a scalar) keeps the promotion today |
require "socket" is mandatory (see require.md); without it the
constants are undefined, as in CRuby.
Supported classes. TCPServer, TCPSocket, UDPSocket, UNIXServer,
UNIXSocket, and the BasicSocket / IPSocket / Socket classes they inherit
from. They sit in CRuby's chain (TCPServer < TCPSocket < IPSocket < BasicSocket < IO), so #is_a?, #class, .superclass and .ancestors
answer as CRuby does. (IO's own mixins, File::Constants and Enumerable, are
still missing, so ancestors diverges past IO.)
Constructors. TCPServer.new(port) / TCPServer.new(host, port),
TCPSocket.new(host, port), UDPSocket.new, UNIXServer.new(path),
UNIXSocket.new(path).
Methods. #accept, #addr, #peeraddr, #local_address,
#remote_address, #bind, #connect, #send, #recv, #recvfrom,
#listen, #shutdown, #setsockopt, #getsockopt, and
the whole IO surface a handle carries (#gets, #read, #readpartial,
#write, #puts, #print, #flush, #close, #closed?, #eof?,
#fileno, #each_line, the IO#wait_* readiness family, IO.select).
#addr / #peeraddr return CRuby's numeric 4-element form. #accept parks
cooperatively on the green-thread scheduler, so a thread-per-connection server
does not stall its siblings, and socket writes bypass stdio (#sync is true,
as in CRuby).
Socket:: constants (SOL_SOCKET, SO_REUSEADDR, AF_INET, SOCK_DGRAM,
SHUT_RDWR, TCP_NODELAY, ...) resolve at run time from the system headers,
so they carry the right platform-specific values.
Class methods. Socket.gethostname, Socket.getaddrinfo, Socket.pair /
.socketpair, Socket.new(domain, type, protocol).
Addrinfo. Addrinfo.tcp / .udp / .ip / .unix, and #ip_address,
#ip_port, #afamily, #pfamily, #socktype, #protocol, #unix_path,
#ipv4?, #ipv6?, #unix?, #ip?, #inspect. #local_address and
#remote_address answer one.
Socket::Option. What #getsockopt returns: #int, #bool, #level,
#optname, #family, #inspect.
Non-blocking. #accept_nonblock, #connect_nonblock, #recv_nonblock,
#read_nonblock, #write_nonblock. Would-block raises the CRuby exception --
IO::EAGAINWaitReadable and friends, which answer to IO::WaitReadable, to
Errno::EAGAIN and to SystemCallError alike -- or, with exception: false,
returns the :wait_readable / :wait_writable marker. O_NONBLOCK is set for
the duration of one call and put back, so a blocking #gets on the same handle
still works.
Divergences and gaps.
- Only the integer-valued socket options are reachable, so
Socket::Optioncarries an int rather than a byte string:#dataand#unpackare missing, andSO_LINGERcannot be read back. Addrinfocovers the address itself, not the resolver surface:Addrinfo.getaddrinfo,#getnameinfo,#to_sockaddr,#canonname,#bind,#connect,#listenare missing.Socket.getaddrinforeturns CRuby's array-of-arrays form, which is the usual way in.#recvfrom_nonblock,#sendmsgand#recvmsgare missing; the rest of the non-blocking family (#accept_nonblock,#connect_nonblock,#recv_nonblock,#read_nonblock,#write_nonblock, and theirexception: falseforms) is supported.SOCKSSocketdoes not exist (CRuby only defines it when built with the SOCKS library, so a program cannot rely on it either).- Missing instance methods:
#getsockname,#getpeername,#do_not_reverse_lookup. - Missing class methods:
.open,.gethostbyname,IPSocket.getaddress. TCPServer.newtakes no backlog argument (the listen backlog is fixed);TCPSocket.newhas no four-argument local-address form.- A class Spinel recognizes but has not implemented reports the missing
method (
undefined method 'new' for class ...), not a missing constant.
- Integer overflow — pick one mode at compile time:
raise(default,RangeErroron overflow),wrap, or--int-overflow=promote(auto-bignum). Not both in one binary, because the representation is chosen statically. See int-overflow.md. - Float
round(ndigits)— the value is always correct; the return class follows CRuby (Integer forroundwith 0 digits, Float otherwise). Proc#ruby2_keywords— not supported (rejected at compile time). It is a migration shim for the Ruby 2.x-to-3.0 keyword-argument transition, flagging a proc so a trailingHashforwarded through*argsis treated as keywords. Spinel targets modern Ruby keyword semantics directly, so the shim has nothing to toggle; there is no 2.x behavior to opt back into.Hash.ruby2_keywords_hash/Hash.ruby2_keywords_hash?are the same shim from the hash side (marking / reading the flag) and are rejected the same way.- A bundled library carries only what it uses. A
requireloads that library and nothing else. CRuby's own stdlib files often pull in others as an implementation detail --require "csv"loadsstringiothere, so a program that requires csv can nameStringIOwithout requiring it -- and Spinel's version of the same library has no reason to make the same internal choice. A program mustrequirewhat it actually uses; the transitive requires of CRuby's implementation are not part of a library's interface. (Spinel's bundled libraries are listed in require.md.) slice_before/slice_afterwith aProcpattern — rejected at compile time (a stored-proc===call per element); use the block form. Range, Class, Regexp, and value patterns are supported.Comparablewith a non-conforming#<=>—<=>is a protocol method returningIntegerornil(aFloatis accepted, compared by sign). A<=>whose result type is statically something else (String,Array,Hash,Symbol, boolean) is a definite protocol violation: any Comparable operator (<,<=,>,>=,==,between?,clamp) on such a receiver is rejected at compile time rather than raising at run time as CRuby does. A<=>whose result is onlypoly/unknown statically keeps the CRuby runtime behavior (an incomparable pair raisesArgumentError).remove_method/undef_method/remove_class_variable— rejected at compile time. Methods are resolved statically and compiled to direct C calls, and class variables to static storage, so there is no runtime table for these to mutate; a construct would remove nothing. The call is reported rather than silently ignored. (A class that defines its own method by one of these names keeps it.)- Frozen literals — explicit
.freezethen mutation raisesFrozenError, matching CRuby. String literals ARE frozen by default here (frozen_string_literal: truesemantics, with no opt-out) — see "String literals are frozen by default" below for what that changes and where mutable strings come from. - Comparable is keyed on
<=>presence — the Comparable operator methods (<,<=,>,>=,between?,clamp) work on any class that defines<=>; CRuby additionally requiresinclude Comparable(aNoMethodErrorotherwise). Spinel does not model the mixin, so it is permissive where CRuby raises.sort/min/max/minmaxneed only<=>in both. Related edges: the comparison-failed message names an operand's class where CRuby inspects special constants (NilClassvsnil);sort_bykeeps incomparable keys in their original order where CRuby raises;include?/indexon arrays of user objects compare by identity unless the class defines its own==. Sorts run a deterministic stable merge (identical on every platform, unlike libcqsort); it matches CRuby's comparison schedule for small arrays, but for larger ones (roughly 8 elements and up, where CRuby switches to its quicksort) the order of tied elements and which incomparable pair the ArgumentError names can differ from CRuby — deterministically so. - Thread data races are observable — Spinel runs threads with real
parallelism and no GVL, so two threads mutating the same
Array/Hash/object without aMutexis undefined at the Ruby level, exactly as in JRuby and TruffleRuby. CRuby's GVL makes individual operations appear atomic; Spinel does not, and adds no implicit per-object locking — correctness across threads is the program's responsibility viaMutex/Queue/ConditionVariable. Relatedly, thread interleaving (and so the ordering ofThread.pass,Thread.listmembership, and the exact moment aThread#raise/#killis delivered) is nondeterministic, where the single-worker model was deterministic.Thread#raise/#killtargeting the main thread is a no-op (main runs on the scheduler's root fiber, which has no inject delivery points); CRuby delivers the exception to main.
Spinel aims to be a subset of Ruby: programs it accepts should behave the same as on CRuby. In a few cases CRuby's behavior depends on a feature Spinel does not implement, and silently returning a wrong value would be worse than a visible error. Those deliberate divergences are listed here.
A method that yields is inlined at every call site, so it is specialized to
the block it is given there. A super reaching such a parent carries the
child's own caller block down, and the chain is inlined the same way -- a
three-link chain, several yields under the super, a class-method super and
super(args) all work.
What does not is a chain of three or more links where two callers pass blocks whose values have different types (one returning an Integer, another a String) and both routes meet at the same yielding ancestor: the middle link carries a single type, so one of the two sites gets the wrong one and the generated C is rejected. Two links are fine -- the parent is specialized per call site there. Give the ancestor its own parameter, or make the block values agree, if a chain that deep needs both.
A missing key on an Integer-valued Hash, or an out-of-range index on an
Integer array, answers nil. Spinel represents that nil as a sentinel
value inside the int slot, so the value is nil for nil?, inspect,
class and ||, and every arithmetic operator on it raises the way CRuby's
nil does: +, -, *, /, % and abs raise NoMethodError (or the
coercion TypeError when the nil is the right operand).
Comparisons (<, >, <=>) and the numeric predicates (zero?,
positive?, ...) still answer from the sentinel rather than raising, because
checking them would put a branch on every integer comparison in the program,
including the loop conditions in the hottest code Spinel generates. A
comparison against a missing key therefore reports a result instead of
raising. Guard the read (h[k]&.positive?, h.fetch(k, 0), or a nil?
test) when the key may be absent.
CRuby evaluates a negative integer exponent to a Rational. Spinel matches
it whenever the sign is knowable: a literal negative exponent types the
result Rational statically (2 ** -1 # => (1/2), 0 ** -1 raises
ZeroDivisionError as in CRuby), and the poly-dispatched path (a
poly-typed base or exponent, e.g. promote-mode parameters) picks Integer
or Rational from the sign at run time. The residual divergence is a
statically int-typed runtime exponent (x ** y with plain int locals):
typing it a sometimes-Rational would force the result poly and cascade
through every int-arithmetic consumer, so a negative value there still
raises RangeError rather than silently truncating. Integer#pow(negative, mod) raises RangeError with CRuby's message.
CRuby returns an exact Rational when the exponent is integer-valued
(3 ** 2r # => (9/1), Rational(3,4) ** 2r # => (9/16)) and a Float
otherwise. Spinel returns a Float in every case (3 ** 2r # => 9.0): the
exactness depends on the exponent's denominator at run time, so honoring it
would force the result to a boxed union and cascade through consumers. The
exponent's magnitude is still correct; only the class (Float vs Rational)
differs. Integer ** Complex and a Complex exponent generally evaluate to
the correct Complex, and Integer#fdiv / #div with a Rational argument
are exact.
A Range is an unboxed value with sp_int bounds, so a Range object over
user objects cannot be built (rng = (Ver.new(1)..Ver.new(9)) is a compile
error naming the class). Comparing against such a range does not need one:
Comparable#clamp folds the bounds straight into the comparison, so the inline
and one-sided forms work.
x.clamp(lo..hi) # works -- no Range is built
x.clamp(lo, hi) # works
x.clamp(..hi) # works (one-sided)
x.clamp(lo..) # works
rng = (lo..hi) # compile error: a Range of Ver objects cannot be built
rng = (0..2**70) # compile error: a Bignum bound does not fit sp_intSpinel resolves what it can resolve at compile time -- that is the point of the AOT model -- and an undefined method is no exception. Where the receiver's type is known and neither the class nor CRuby's own surface for it carries the name, the call cannot succeed under any input, so it is reported when the program is built rather than left to raise:
[1].nope # spinel: t.rb:1: undefined method 'nope' for an instance of Array (NoMethodError)
:s.nope # ... for an instance of Symbol
Plain.new.nope # ... for an instance of PlainThe diagnostic is CRuby's own wording, so the message reads the same as the
exception would; only the moment differs. The consequence is that a program
which only reaches such a call behind a rescue NoMethodError cannot be
built:
r = (begin; [1].nope; rescue NoMethodError => e; e.receiver; end) # compile errorA receiver whose type is not statically known -- a nil, a boxed value read
out of a container, a poly union -- keeps the runtime raise, since nothing
could be proved about it at compile time:
x = nil
r = (begin; x.nope; rescue NoMethodError; "runtime"; end) # => "runtime"A name CRuby does define on that class, which Spinel has not implemented, is
a different thing entirely: that is a gap in Spinel, and it reports itself as
an unsupported call naming the node, not as a NoMethodError.
An integer Range is a value with sp_int bounds, which have no
representation for an infinity: the value can only record "unbounded". Where
that loses information CRuby keeps, Spinel resolves it as follows.
A range whose begin is infinite (-Float::INFINITY..5) takes the Float
representation, so #begin answers -Infinity as CRuby does. Its finite end
then reports as a Float:
(-Float::INFINITY..5).begin # => -Infinity (as CRuby)
(-Float::INFINITY..5).cover?(0) # => true (as CRuby)
(-Float::INFINITY..5).end # => 5.0 (CRuby: 5)
(-Float::INFINITY..5).to_s # => "-Infinity..5.0" (CRuby: "-Infinity..5")A range whose end is infinite (1..Float::INFINITY) keeps the integer
representation -- it is the canonical lazy source, and its integer enumeration
is what a fused .lazy pipeline walks. #end, #size and #to_s read the
bound off the literal, so they answer as CRuby does; a range of that shape held
in a variable and asked for #end answers nil (the value records only that
it is unbounded):
(1..Float::INFINITY).end # => Infinity (as CRuby)
(1..Float::INFINITY).size # => Infinity (as CRuby)
(1..Float::INFINITY).to_s # => "1..Infinity" (as CRuby)
r = (1..Float::INFINITY); r.end # => nil (CRuby: Infinity)A finite mixed range (1..5.0) keeps the integer representation, where its
#to_a, #sum and #cover? are all right and its iteration is the integer
one CRuby performs; only #end reports 5 where CRuby reports 5.0. A
one-sided float range ((..5.0), (1.0..)) likewise keeps it, so #to_s
renders the bound as an integer ("..5").
A String-bounded range (("a".."e")) is its own value type, so it keeps its
class, #to_s and #inspect whether it is used inline or held in a variable.
Its endpoint and membership methods (begin/end/min/max/cover?/===)
answer directly; every traversal (each, map, to_a, ...) materializes the
element array through String#succ, so an unbounded string range cannot be
iterated. #size is nil, as in CRuby, since a string range has no integer
element count.
A Time value stores its sub-second as an integer nanosecond count
(int32 tv_nsec), like struct timespec. A Rational sub-second argument
that does not fall on a nanosecond boundary is rounded to the nearest
nanosecond at construction:
t = Time.utc(2020, 1, 1, 0, 0, 0, Rational(1, 3)) # 1/3 microsecond
t.subsec # => (333/1000000000) (CRuby: (1/3000000))
p t # => 2020-01-01 00:00:00.000000333 UTC
# (CRuby: 2020-01-01 00:00:00 1/3000000 UTC)Everything representable in whole nanoseconds -- every Integer usec, and
any Rational whose value lands on a nanosecond -- is exact, and to_s,
usec, nsec and strftime("%N") agree with CRuby. Only sub-nanosecond
exactness (and, as its consequence, the Rational-form #inspect display
CRuby uses for non-decimal sub-seconds) is lost.
Complex, Rational, and Range values are unboxed C structs with no
internal pointers, so there is no per-object address to observe: equal?
(and object_id comparisons) are component equality. x.equal?(x) is true
as in CRuby, but two separately-constructed equal values also compare
equal? (CRuby: false). This extends the treatment CRuby itself applies to
its immediate values -- 1.equal?(1), :s.equal?(:s), and (on 64-bit)
1.0.equal?(1.0) are all true there because the value is the identity.
The same applies to freeze on these values: they are value-frozen already
(frozen? is true), and freeze is an identity no-op.
/i folds one codepoint to one. Case-insensitive matching uses Unicode
simple case folding, so /ä/i matches "Ä" and /k/i matches "K" (U+212A).
A source whose fold is several codepoints has no single counterpart to fold
to and is matched literally: "ß" =~ /ss/i is nil where CRuby answers 0.
Building the regexp engine with -DRE_NO_UNICODE_CASE (make RE_CASE_FLAGS=-DRE_NO_UNICODE_CASE) leaves the ~3KB fold table out and folds
ASCII alone; a non-ASCII literal then matches literally under /i too.
An --rbs seed is enforced where a value crosses into it. A parameter
seeded Hash[Symbol, untyped] handed a hash whose keys the caller widened to
any type converts at the call, and a key the declared type cannot hold raises
TypeError there. CRuby ignores the signature, so a program whose keys really
are Symbols agrees with it and one whose keys are not diverges: the seed is a
claim about the program, and this is where the claim is checked.
Regexp literals share one compiled object. Each pattern is compiled once
at startup and every textually-equal literal names that one object, so
/ab/.equal?(/ab/) is true (CRuby allocates per literal: false). Same
shared-immutable-storage treatment as above; ==/eql?/matching are
unaffected.
String literals are frozen by default (frozen_string_literal: true
semantics). Spinel's baseline is the direction Ruby itself is headed
(plain CRuby already warns "literal string will be frozen in the future"):
a literal is frozen ("lit".frozen? is true), mutating one raises
FrozenError, and mutable strings come from +"lit", String.new,
interpolation, or dup -- exactly as under CRuby's
--enable=frozen-string-literal. There is no opt-out: a
# frozen_string_literal: false magic comment warns at compile time and
is ignored, and --disable=frozen-string-literal is rejected. (The
whole-program shared-mutable-string machinery relies on the frozen-literal
guarantee; a chilled mode would be a second, subtly different mutation
semantics.)
A note on identity: what frozen_string_literal specifies is frozenness,
not object identity, so the identity of frozen literals is
implementation-dependent. CRuby happens to intern equal-content literals
("abc".equal?("abc") is true there); Spinel compiles each literal
OCCURRENCE to its own static object, so the same comparison answers
false, while re-evaluating one occurrence (a literal in a loop) yields
the same object where plain CRuby would allocate per evaluation. Programs
should not depend on either arrangement -- equal?/object_id on frozen
literals is exactly the implementation-defined corner. Value semantics
(==, hashing, matching) are unaffected.
Aliased in-place mutation is observed. A mutable string (from
String.new, +"lit", interpolation, or dup) that is both aliased and mutated in
place shares one mutable buffer, matching CRuby's mutable String objects:
the mutation is visible through every reference and equal? across the
alias set is true. This covers the full in-place mutator surface --
<<, concat, prepend, replace, insert, clear, slice!, index
assignment (s[i] = x), setbyte, bytesplice, append_as_bytes, and
the transforming bang methods (upcase!, gsub!, strip!, reverse!,
...) -- across every storage shape: local aliases, array elements and hash
values (stored or read back, including mutation THROUGH a container read
like arr[0].upcase!), instance variables (with attr and hand-written
readers), method parameters (a callee's mutation stays visible through the
caller's aliases), returned values (including a string the callee also
retained), closure captures, and iteration variables. Frozen strings keep
raising FrozenError through every path; a hash string KEY is
snapshot-frozen on store, exactly CRuby's dup-and-freeze. Strings never
mutated in place, or mutated but never aliased, keep the plain value
representation (no cost).
CRuby's blockless (1..10).step(2), (1..10) % 2 and 1.step(5) all return
an Enumerator::ArithmeticSequence: a lazy object with its own inspect
(((1..10).%(2))) and its own readers. Spinel has no ArithmeticSequence class
and materializes the stepped values at the call, as an Array. The values are
CRuby's, and so is everything computed from them: to_a, each, map,
select, first, first(n), size, sum, include?, each_slice,
reverse_each and == all agree.
What differs is the object, not the values. .class answers Array, p on
the unforced sequence prints the array rather than ((1..10).%(2)), and the
readers only an ArithmeticSequence has -- begin, end, step,
exclude_end?, with_index -- are not Array methods, so they raise
NoMethodError naming Array. A sequence that has to be described rather than
enumerated should be asked of the source range, which is unchanged.
Materializing also bounds what can be stepped at all. CRuby's sequence is lazy,
so (1..).step(2).first(3) and (1..3_000_000_000).step(2).size cost it
nothing; spinel would have to build every element, and refuses past 2**30 of
them with RangeError: range too large to materialize. An endless range hits
the same limit, its end being the largest representable integer.
Materializing at the call also decides when a bad stride is rejected. CRuby
defers the check to the point the sequence is enumerated, so (1..10).step("x")
returns an Enumerator, .size answers nil, and the TypeError arrives only
on to_a / each / first. Spinel raises the same TypeError, with CRuby's
message, at the call itself. A String stride on a String range differs
further: since 3.4 CRuby steps a non-numeric range by repeated +, so
("a".."e").step("x") diverges, while spinel takes every Nth element -- a
stride a String cannot name -- and raises.
Strings store embedded NUL bytes, and the byte-exact core matches CRuby:
literals ("a\0b"), length / bytesize / bytes, == ("a\0b" == "a"
is false), Hash keys, slicing (s[i], s[a, n], ranges, byteslice),
dup / clone, concatenation, 0.chr, File.write / File.read
round-trips, StringIO, pack/unpack, and Marshal.
The transform and search methods walk the C string and stop at the first
NUL: case ops (upcase, ...), strip family, index / include? /
start_with?, sub / gsub / tr / delete / squeeze, split,
reverse, succ, and interpolation / % formatting ("x#{s}y" drops
the NUL and its tail). inspect renders \x00 where CRuby prints
\u0000. Treat embedded-NUL strings as byte containers, not text to
transform; full binary-safe transforms are a possible future project.
module Encoding at the top level is CRuby's TypeError (Encoding is not a module) and Spinel reports the same error at compile time. A nested
module Foo::Encoding (or class Foo; module Encoding; end; end) is legal
CRuby — it names a fresh constant — but Spinel's generated C type for a class
or module is its bare tail name, which collides with the runtime's own
sp_Encoding type. Spinel refuses these at compile time with
unsupported module name '<Name>': collides with the builtin class of that name instead of failing with a raw C error. Renaming the nested module
avoids it. Builtin modules (Comparable, Kernel, Math, …) reopen
normally at any nesting level.
The legacy form Struct.new("Foo", :a, :b) registers the new class as the
constant Struct::Foo. Spinel does not support this: a class is a compile-time
entity here, and the whole point of the string name — a class installed under
the Struct:: namespace and reached through Struct::Foo — has no analogue in
the ahead-of-time model. Spinel refuses both the string-named definition and any
Struct::Name reference at compile time with Struct.new with a string name … is not supported; use \Name = Struct.new(...)``. Use the modern constant-
assignment form, which is equivalent and idiomatic:
Foo = Struct.new(:a, :b) # not Struct.new("Foo", :a, :b)Rational is stored as a pair of fixed sp_int numerator/denominator. The
arithmetic is exact while the reduced terms fit in sp_int; an operation whose
result would overflow raises RangeError rather than promoting to a Bigint as
CRuby does:
Rational(10**18, 1) * Rational(10**18, 1) # RangeError (CRuby: a Bigint Rational)Complex stores its components as sp_float plus a per-component class tag,
so #real / #imaginary / #abs2 and display report Integer components like
CRuby for integer-valued inputs. What the representation cannot express is a
Rational component: operations that would produce one compute in floats
instead. This applies to exact division and to mixed Complex/Rational
arithmetic and construction, which coerce the Rational via #to_f (the
operations work; only the component class -- and therefore the printed form --
differs from CRuby):
Complex(1, 2).real # => 1 (matches CRuby)
Complex(1, 2) / Complex(3, -1) # => (0.1+0.7i) (CRuby: ((1/10)+(7/10)*i))
Complex(1, 2) + Rational(1, 2) # => (1.5+2i) (CRuby: ((3/2)+2i))
Rational(1, 2) + Complex(1, 2) # => (1.5+2i) (CRuby: ((3/2)+2i))
Complex(Rational(1, 2), Rational(1, 3)) # => (0.5+0.3333333333333333i)
# (CRuby: ((1/2)+(1/3)*i))
Rational(3, 4).i # => (0+0.75i) (CRuby: (0+(3/4)*i))Rational and Complex values box into heterogeneous (poly) arrays and hashes
normally.
CRuby promotes (-2.0) ** 0.5 to a Complex. Spinel's Float stays a C
double, so that case raises Math::DomainError loudly (the class
Math.sqrt(-1) uses) rather than returning C's silent NaN or widening
every float power to a boxed union. Compute via Complex(x) ** y where the
complex result is really wanted.
CRuby answers defined?(@ivar) from the object's runtime state: nil until
the instance variable is first assigned, "instance-variable" after — which
is what makes it usable as a memoization guard for falsy values
(return @x if defined?(@x)).
Spinel's instance variables are C struct fields. Every ivar the program
mentions exists in the object's layout from allocation, pre-filled with its
type's nil representation; there is no per-object "has been assigned" record
to consult. defined?(@ivar) therefore folds at compile time: it is truthy
iff the program contains an assignment to that ivar anywhere, regardless of
whether this object has been assigned yet at run time. The falsy-value
memoization pattern silently reads the unassigned slot on the first call:
def foo
return @foo if defined?(@foo) # compile-time truthy: an @foo= exists below
@foo = compute # never reached — foo returns nil forever
endTracking runtime assignment would need a shadow presence bit per ivar written
on every assignment — cost on every object and every ivar write to serve a
rare pattern. Use a nil check (@foo = compute if @foo.nil?, i.e. ||=) when
compute never yields nil/false, or an explicit sentinel/flag ivar when it
can:
def foo
return @foo if @foo_set
@foo_set = true
@foo = compute
endcompare_by_identity is rejected at compile time (never silently ignored).
Spinel's hashes are typed storage variants keyed by VALUE -- string keys hash
and compare by content, and string literals are shared through a frozen pool.
Identity-keyed comparison cannot be honored on that representation: two
equal-content String keys may be the same object in Spinel where CRuby sees
two distinct ones, so even a dedicated identity mode would diverge from CRuby
on the exact programs that need it. Restructure identity-keyed lookups to use
an explicit unique key (an Integer id, a Symbol) instead.
equal? on strings is pointer identity. Each literal OCCURRENCE compiles
to its own frozen static object (see the identity note under the
frozen-string-literal section): "x".equal?("x") is false, and
re-evaluating one occurrence (a literal in a loop) yields the same object.
Both facets are implementation-defined under frozen_string_literal
semantics and programs should not depend on them. Everything else about
identity is truthful: s.freeze.equal?(s) is true (freeze marks in
place), aliasing compares equal, -lit dedups interned content to one
object, and distinct-valued strings compare false.
defined? is resolved statically at compile time from the operand's
syntactic form and whole-program symbol presence, not from the runtime state of
the actual receiver or binding. It returns a fixed label string (or nil),
which matches CRuby for the common forms but differs in several cases. A full
runtime-accurate defined? would require carrying per-object/per-binding
definedness into the generated code; that cost is deliberately not paid.
Forms that match CRuby: a local variable ("local-variable"), a set/unset
instance variable, a set/unset global variable, a user or built-in constant
name ("constant"), a no-receiver call to a user-defined method
("method"), self, nil/true/false, and the int/float/string/symbol/array
literals that report "expression".
Where Spinel returns nil but CRuby returns a label:
| Operand | CRuby | Spinel |
|---|---|---|
Foo::Bar (constant path) |
"constant" |
nil |
puts (built-in / Kernel method) |
"method" |
nil |
obj.meth (call with a receiver) |
"method" |
nil |
1 + 1 (operator = method call) |
"method" |
nil |
{a: 1}, 1..3 (hash/range and other general expressions) |
"expression" |
nil |
x = 1 (assignment) |
"assignment" |
nil |
yield, super |
"yield" / "super" |
nil |
| Multi-encoding strings | Strings are UTF-8, always: Encoding conversion tables, force_encoding to non-UTF-8 encodings, and per-string encoding state are out of scope. A compiled binary's string paths (indexing, regexp, hashing) assume one encoding, and that assumption is load-bearing for their performance |
write UTF-8; transcode at the boundary before the data enters the program |
Two forms report a label where CRuby would report nil, because the check is
syntactic rather than runtime:
- An instance variable reports
"instance-variable"when any code in the program assigns that ivar name -- not whether it is set on the specific receiver at that point. - A class variable read always reports
"class variable", with no definedness check, sodefined?(@@undefined)is"class variable"in Spinel versusnilin CRuby.
Correct Unicode extended-grapheme segmentation ("á".grapheme_clusters # => ["á"])
requires shipping and maintaining the Unicode grapheme-break property tables,
which Spinel deliberately does not carry. String#grapheme_clusters and
String#each_grapheme_cluster are therefore not supported. For codepoint- or
byte-level iteration, use the supported String#chars, #each_char,
#codepoints, or #bytes.
Unicode normalization ("é".unicode_normalize(:nfc) # => "é") requires
shipping and maintaining the Unicode decomposition/composition tables, which
Spinel deliberately does not carry -- the same limit as
String#grapheme_clusters above. String#unicode_normalize,
#unicode_normalize!, and #unicode_normalized? are therefore not supported,
and a call to them is rejected at compile time.
Spinel's Time stores an int64 second count and an int32 nanosecond
fraction (nanosecond resolution). CRuby keeps the exact rational a Float or
Rational argument produces, so it carries bits below one nanosecond.
#nsec / #usec agree with CRuby (both truncate to the nanosecond), but two
things differ: Time.at(f).to_f does not always round-trip a Float (CRuby
rounds the exact rational to the nearest double; Spinel reconstructs from the
nanosecond value, so Time.at(12345.678).to_f is 12345.677999999), and
#subsec returns a nanosecond-resolution Rational (Time.at(2.2).subsec is
(1/5)) rather than the exact binary fraction of the source Float.
CRuby's English library aliases the punctuation match globals to readable
names (alias $MATCH $&, etc.). In Spinel the match globals ($&, $`,
$', $+, $~) are not ordinary global-variable storage: a direct read lowers
to a special regexp runtime accessor. Supporting alias $name $& would require a
separate special-global alias mechanism plus broader MatchData compatibility,
outside the intended AOT subset. Aliasing one of these globals is rejected at
compile time rather than falling through to an undefined generated symbol:
$ spinel uses_english.rb
Error: global aliasing of regexp special globals is not supported (alias $MATCH $&)
Direct reads of the match globals work as usual; only aliasing them is
unsupported, so require "English" does not compile.
CRuby supports the flip-flop operator (a Range used as a condition, toggled
between its two endpoints): puts i if (i == 3)..(i == 5). This is a rarely used
feature with surprising hidden per-site state, and Spinel does not support it; a
program using it fails to compile rather than running with wrong behavior. Use an
explicit boolean state variable instead.
These were limits in an earlier (Ruby self-hosted) version of the compiler and now work on current master:
| Feature | Status |
|---|---|
Mutable strings and aliased in-place mutation (s = +"x"; s << "y"; literals are frozen by default — see the String section) |
works |
Hash missing key → nil (string- and int-keyed, including Hash.new(default)) |
works |
define_method(:name) { ... } with a literal name |
works |
Block-param arity (un-yielded params are nil, not a sentinel) |
works |
Closures flowing through containers ({op: ->(a,b){a+b}}[:op].call(2,3)) |
works |
String#oct (0x/0b/0o prefixes) and Array#first on empty → nil |
works |
send(:literal) / __send__("literal") / public_send(:literal) on implicit self |
works (resolved on the AST, so a send(: inside a string literal is left untouched) |
| Hash variant inference (a wrong initial guess widens to poly transparently) | correct (a perf cost, not a correctness limit) |
There is no Ruby self-host "bootstrap fixpoint" constraint: the C compiler is the master implementation.
Most real programs use the dynamic features above sparingly, in setup code, or not at all. Spinel targets the large static core of Ruby — classes, methods, blocks, the collection protocols, exceptions, mixins — and compiles it to fast native code. When a program does need a feature in the fundamental table, that program is not a fit for AOT; for everything else, the limits are either by design or on the relaxable list.