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).
A construct rejected at compile time is a refusal: a
spinel: FILE:LINE: ... line on stderr naming the construct. By default, one
run reports every refusal it encounters in the program
(each abandons the method it is in and the compile goes on to the next), then
fails once with the count and writes no binary. --emit-types carries the same
refusals in its diagnostics array with "severity":"error".
Some unsupported constructs compile and fail only when reached at runtime.
For example, a computed instance_variable_set on a user-class instance can
raise NoMethodError; computed getters on such receivers are refused at
compile time. define_method with a non-literal name, in a class body or a
method body, and Class.new with a non-constant superclass are refused at
compile time. Class.new and Module.new blocks that capture outer locals
are refused at compile time unless --defer-refusals is supplied.
A successful compile does not establish CRuby-compatible behavior.
Separately, --defer-refusals explicitly turns eligible compile-time
refusals into runtime NotImplementedErrors.
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.) Code in a branch a RUBY_ENGINE == "..." check rules out (if RUBY_ENGINE == "spinel" ... else eval(...) end) is dropped before analysis, so a library can keep an eval backend for other engines. |
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 |
calls with a non-literal name, in a class body or in a method body (instance or class method), are refused in reachable code, even when the defined method is never called; an unreachable method and pruned code are not refused. A program that defines or aliases its own define_method keeps those calls as ordinary calls; that check is program-wide, so a def define_method in an unrelated class turns the refusal off everywhere, and a computed-name class-building call then raises NoMethodError at run time as it always did. send(:define_method, name) is not refused either. Literal Symbol/String names and existing literal-list unrolling work, and so does a class method, or a method of a module the class extends, that the class body calls with literal arguments and that hands define_method a block or a lambda under a name it builds from them (def self.flag(name) = define_method("#{name}?") { ... }, then flag :color): each call is expanded into the defs it makes. A constant holding a Symbol or String name is not supported yet |
the compiled method table cannot acquire a runtime-built method |
ObjectSpace (each_object, count_objects) |
unsupported | no class-keyed allocation registry; the GC tracks bytes, not a live-object index. define_finalizer / undefine_finalizer are supported (the collector watches the object; the callable runs at the first safe point after the collection that frees it -- a method entry, a loop back-edge, GC.start -- or at exit) |
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 |
A seeded Random.new(n) or srand(n) |
draws a different sequence from CRuby's | the generator is PCG, not Mersenne Twister, so Random.new(1).rand(6) is 0 where CRuby answers 5; a program that prints seeded draws differs from CRuby's, and one that only needs them reproducible does not |
callcc / Continuation |
unsupported | multi-shot full-stack capture has no flat-C analogue |
Class.new(parent) with or without a block |
static forms work; non-constant superclass expressions are refused at compile time | the class graph is baked at compile time; see Class.new cases below |
An instance variable of a String (@x = v in a method added to String, s.instance_variable_set(:@x, v)) |
refused at compile time, until Strings are shared rather than copied (#6765); one reached through an untyped value raises NotImplementedError when it runs, as does one on a Time | a String is copied between its representations and across calls, so it has no one identity yet; under #6765's share-by-default model it keeps one and takes the same map as an Array. A Time is copied by value. An Array, a Hash, a Random, a Proc, an exception and a class value keep their instance variables, in a table keyed by the object (as CRuby's); a class value's own class-level slots stay where its class methods read them, and its instance_variables lists those only its class methods wrote after the reflective sets. An Integer, a Float, a Symbol, nil, true, false and a Range read nil and raise FrozenError on a write, as in CRuby. An ivar of a builtin value as a multiple-assignment target (@a, @b = x, y in an Array method) is refused: assign each on its own |
A subclass of a builtin value class: class Name < String, and likewise Range, Proc, Method, UnboundMethod, Integer, Float, Symbol, Rational, Complex, NilClass, TrueClass, FalseClass, Regexp, MatchData, Time, Random, Enumerator, IO, File, Dir, Thread, Fiber, Mutex, Queue, SizedQueue, ConditionVariable, OpenStruct, or a class a package binds to C (StringIO); also Thread::Queue |
refused at compile time, naming the class | the subclass would be built as a plain object: none of the parent's methods reach it, its constructor takes none of the parent's arguments, and p, to_s, == and respond_to? answer as for an Object. Supporting it needs an instance that IS a String (Range, ...) with the subclass's methods dispatched on it, as an Array or a Hash subclass's is (below); until then, keep the value in an instance variable of a class of your own. A subclass of Object, BasicObject, an exception, Struct / Data, Numeric, or a package class written in Ruby (Set, Date, BigDecimal) works, as does a class of the program's own that shares a builtin's name under a namespace (Jobs::Queue) |
A subclass of Array (class Page < Array, ::Array, Class.new(Array) do ... end) |
supported (#7449), except the shapes in the next column, which are refused at compile time | its instance IS its Array: the struct starts with the Array, so Array's methods run on it, a boxed one is an Array to the runtime, and its class is read back off its own GC scan function. Refused: Class.new(Array) without a block (no class of the program's own stands for it; write class Name < Array), and a bare super into Array from a method with keyword, post-rest or destructured parameters (pass the arguments explicitly). Marshal.dump writes an instance as CRuby does (C with the class, the elements, and its ivars under I), and Marshal.load reads that back as an instance of the class, whether Spinel or CRuby wrote it |
A subclass of Hash (class Registry < Hash, Class.new(Hash) do ... end) |
supported, except the shapes in the next column, which are refused at compile time | its instance IS its Hash, as an Array subclass's is its Array: Hash's methods run on it (merge, compact, dup and clone answer an instance of the class, select, slice, to_h a plain Hash, as in CRuby), super in initialize takes Hash.new's default and default block, and a reopen of Hash beside it (activesupport's core extensions) gives it the reopen's methods. An alias_method of one of Hash's methods ahead of the class's own definition (alias_method :regular_writer, :[]=) keeps naming Hash's, in an Array subclass too. Refused: Class.new(Hash) without a block, and a bare super into Hash from a method with keyword, post-rest or destructured parameters |
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 are refused at compile time, naming the Ruby line, when the body needs a self (its own @ivar, or self); a body that needs neither compiles as an ordinary function and is simply never reached as a method |
Singleton definitions (def Queue.helper, class << Queue) reopening a builtin whose class body is unsupported |
refused at compile time | the same reopening limits apply to reachable singleton methods, including Queue, SizedQueue, Mutex, ConditionVariable and Encoding. An unreachable singleton method is ignored; class-body reopenings remain refused. |
alias / alias_method capturing a builtin singleton method before redefining it |
refused at compile time | the alias must keep the original builtin implementation. Redirecting it to the later user override could recurse. An alias of a user singleton method already defined remains supported. Calling a captured builtin singleton alias through a constant is also refused. |
super from a singleton override to an inherited builtin singleton method, such as File.read or File.open reaching IO, or Kernel.puts reaching the Kernel instance method through Object |
refused at compile time | this dispatch has no compiled user method to call. A super that resolves to a user singleton method remains supported; a method with no superclass implementation still raises NoMethodError. |
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. singleton_class.prepend(Mod) / singleton_class.include(Mod) as a statement of a class or module body (activesupport's const_missing hook on Enumerable) is read as extend Mod, the precedence between Mod and the class's own singleton methods aside |
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 |
A call through an @ivar before the method that assigns it has run (@store[k] = v ahead of a reset that sets @store = {}) |
a release build dereferences the unset slot and crashes (SIGSEGV); a -g / --debug build raises CRuby's NoMethodError (undefined method '[]=' for nil) |
the test would stand in front of every call through such an ivar, and state a reset or setup method assigns is often the hottest there is (optcarrot lost 10-20% to it), so only the debug build carries it. An ivar the program fills only by memoization (`@c |
An ivar write in an Object or Kernel method with boxed self |
uses the existing reflective setter; refused if a possible receiver has no writable slot, or the default build encounters a shared String handle slot; --share-strings carries the handle through the setter |
ordinary user-class slots are registered by the existing boxed-receiver analysis, including a class that starts with no ivars; its indexed call sites bound the receiver layouts, while calls it cannot follow remain conservative; an unused setter adds no instance slots; no write is silently dropped |
defined?(@x) in an Object or Kernel method whose receiver is boxed |
refused when the existing layout facts cannot track whether a possible receiver's slot has been assigned | an initialized slot, a slot whose value distinguishes unset, or a slot with an explicit presence bit is supported; a slot introduced by a root setter keeps its presence bit even if an unrelated class writes an ivar of the same name; mixed ordinary/reflective nil writes to related layouts can leave presence untracked |
General reflection (methods, instance_variables) and instance_variable_get/set with a non-literal name |
non-literal instance_variable_get calls whose receiver may be a user-class instance are refused in reachable code, including input-dependent branches; computed instance_variable_set on user instances remains unsupported and can raise NoMethodError at runtime |
user-class ivars are C struct offsets; literal access resolves to the known offset. Builtin reflective paths and ordinary ivar access in Object methods keep working. Computed setters are not refused: test/reflect_ivar_dynamic_name.rb contains a reachable conditional setter whose untaken branch already works. instance_variables can list these known fields. A literal instance_variable_get(:@x) / instance_variable_set(:@x, v) is supported, 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) |
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. |
Time.parse / Time.strptime / Time.iso8601 / Time.xmlschema / Time.httpdate / Time.rfc2822 / Time.rfc822 (the require "time" string-parsing additions) |
refused at compile time, naming the limit | the built-in Time class (Time.now / at / local / utc, plus strftime / zone) works without any require, and require "time"'s other additions (the instance methods iso8601, httpdate, rfc2822) work too; only parsing a String into a Time is missing. Store times as epoch seconds and read them with Time.at instead. A program that reopens Time with its own class method of one of these names keeps calling that method, not this limit. |
net/http / uri. An HTTP/1.1 client with Connection: close, one
request per connection -- a second request inside one Net::HTTP.start block
reconnects transparently, as CRuby does, rather than failing, but the
connection is not reused: no keep-alive, no pipelining, no HTTP/2, no proxy,
no cookie jar and no automatic redirect following (a 3xx comes back as the
response it is, with its Location). Chunked transfer decoding and automatic
gzip/deflate content decoding are there. Content decoding reads the whole body
before inflating it with the bundled one-shot zlib codec; it is not streaming.
The codec can reject incomplete streams or trailing bytes with Zlib::DataError
where CRuby's streaming inflater returns a body. An explicit Accept-Encoding
disables automatic decoding, and Content-Range responses retain their wire
bytes. URI parses http, https and a bare form; there is no URI::FTP or the
scheme registry behind it. An https request needs the
openssl package below.
TLS / openssl. The openssl package binds the system libssl and
provides OpenSSL::SSL only: SSLContext, SSLSocket, SSLError and the
VERIFY_* constants, which is what an outbound HTTPS client reaches.
OpenSSL::Digest (SHA256 / SHA1 / MD5, the class-method forms and the
object: OpenSSL::Digest.new("SHA256"), update / <<, digest,
hexdigest, digest_length), OpenSSL::HMAC, OpenSSL::KDF.hkdf /
pbkdf2_hmac and OpenSSL::PKCS5.pbkdf2_hmac are there, over the runtime's
own crypto rather than libssl, and no HMAC-MD5; the digest object buffers its
input and hashes it whole. Cipher (aes-gcm) and PKey::EC are subsets,
each described in its file under packages/openssl/openssl/. Most of X509
is not there: a call to a method the package does not define compiles into
CRuby's NoMethodError, raised when it is reached. Spinel
implements no TLS and bundles no trust anchors: the chain is validated against
the operating system's store, so a CA it stops trusting stops being trusted
here on an OS update. The package exists only where libssl's headers were
present at build time.
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 --require-gate (or
SPINEL_REQUIRE_GATE=1); spin build always compiles with it on.
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
computed name (an interpolation, to_sym, a concatenation) sent to a user-class
receiver dispatches over everything that class answers -- its methods, readers and
writers, and the Object methods every instance has -- so the set is complete; on a
boxed receiver it covers every user class plus the program's literals. A computed
name sent to a builtin receiver (a String, an Array, ...) is still refused at compile
time. A literal name is fully resolved -- see below.
public_send(*args, &blk) with the name as the first element of a splatted
variable (activesupport's Object#try) dispatches too: the arms take
*args.drop(1). The send's block -- written at the send or a forwarded &blk
-- goes to the method the name selects. A receiverless send / public_send
in a method is the self. form. respond_to?(name) with a runtime name is
answered over the same closed set (false outside it).
The assignment target, superclass expression and presence of a block all
matter. Assume an ordinary user-defined class Base; end and base = Base.
Each row below is a separate program; the blocks shown capture no outer locals.
| Assignment | Compile | Run / subsequent use |
|---|---|---|
Foo = Class.new(Base) {} |
succeeds | works |
foo = Class.new(Base) {} |
succeeds | name is nil until a constant assignment executes; to_s/inspect use the anonymous class address form |
Foo = Class.new(Base) |
succeeds | works |
foo = Class.new(Base) |
succeeds | same anonymous naming as the block form |
Foo = Class.new(Mod) or foo = Class.new(Mod) with Mod a module |
succeeds | raises TypeError where the call runs, as CRuby does |
Foo = Class.new(base) {} |
refused | no binary |
foo = Class.new(base) {} |
refused | no binary |
Foo = Class.new(base) |
refused | no binary |
foo = Class.new(base) |
refused | no binary |
An instance of an unnamed class inspects as #<#<Class:0x...>:0x... @a=1>,
and as #<Foo:0x... @a=1> once a constant assignment names the class.
At top level or in a class body outside a method, loop or block, omitting the block with a constant superclass is equivalent to an empty block. No-block calls in a method, loop or block retain their existing handling; this lowering does not give them fresh class identity on each evaluation. A local holding a class is still not a supported superclass expression. For example, the first assignment works, but the second is unsupported:
class Base; end
foo = Class.new(Base) # works
bar = Class.new(foo)Use constants for the superclass expressions to build the chain statically:
class Base; end
Foo = Class.new(Base) {}
Bar = Class.new(Foo) {}
p Bar.superclass == Foo # trueSpinel lowers the supported forms to static class definitions when the superclass is omitted or named by a constant and the block captures no outer locals. A constant assignment names the class; a local assignment or other expression uses a synthesized class. A method can return such a class too:
class Base; end
k = Class.new(Base) { def hi = 1 }
p k.new.hi # 1
def make = Class.new(Base) { def hi = 1 }
p make.new.hi # 1
p make == make # true (CRuby: false)Each evaluation of the same supported Class.new expression yields the same
compiled class; CRuby creates a new class on each evaluation. Factories with
a block remain accepted, including programs that do not observe class identity.
Calls without a block in a method, loop or block are not lowered, so they gain
no support for repeated construction.
A Class.new or Module.new block that captures outer locals is refused at
compile time for constant assignments, local assignments and reachable method
returns. An unreachable method remains pruned, including any captured
class-building block in its body. A Proc or lambda that nothing reads, whether
held by a local, a constant, an instance variable or a global, does not consume
the capture refusal either. Reachability is approximate in the other
direction: a Proc passed to a method that never calls it, and a method called
only from an uncalled lambda, count as reachable, so a capture inside them is
still refused. With --defer-refusals,
it retains the runtime NotImplementedError instead. Locals defined inside
the class-building block are not outer captures.
A non-constant superclass expression is refused in reachable code, with or
without a block and regardless of the assignment target. Pruned scopes are
not refused. Even base = Base
aliases are not supported yet; use Class.new(Base) instead. A constant alias
of a class (S = Base; Class.new(S)) is accepted but loses Base's methods, as
it did before.
Constant builtin parents use the same lowering where supported; for example,
Err = Class.new(StandardError) works without a block. Array and Hash retain
the no-block refusals described above.
Limited today, but additively fixable; listed roughly easiest-first.
| Feature | Today | Path to relax |
|---|---|---|
Exception#backtrace / Kernel#caller |
return [] in a release build (class + message work). A --debug build, or -g with -O0 / -O1, names each frame file:line:in 'Class#method' with the file the method was written in and the line of the call. The line comes from the build's own debug info, through addr2line on Linux (which must be installed) and atos on macOS (reading the .dSYM the build leaves beside the program); without them a frame has no line. -g at the default -O2 drops the frames the C compiler inlined (a method called from one place usually is), so use --debug for a backtrace |
a release build would need a pc→line table in every binary (#7658) |
SIGINT / SIGTERM with no trap |
a program that starts no thread raises Interrupt (#signo 2) / SignalException (#signo 15) in the main thread, as CRuby does, so rescue and ensure run; unrescued, it flushes its output and ends by the signal (a parent reads 130). A program that starts a thread keeps the system default: the process ends at once, with no rescue or ensure |
deliver the raise to the main Ruby thread through the scheduler, whichever OS thread the signal reaches |
class Thread / class Fiber reopenings, Thread.attr_accessor :x / Fiber.attr_accessor :x (activesupport's IsolatedExecutionState) |
supported | a reopening's instance methods take the runtime handle as self, and Thread.current / Fiber.current reach them (also through a class value or a class held in a poly slot). A thread's attribute lives in its thread-local table under a private key; a fiber's in a table of the fiber's own that a new fiber does not inherit (an attribute on a fresh fiber is nil). thread_variable_get / _set / ? share the store Thread#[] / []= / key? keep: one table per thread for both, where CRuby's [] is fiber-local |
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 lifecycle hooks (def self.included(base) / def self.extended(base) with one required parameter) |
fired for include M in a class body and extend M in a class or module body, with a literal module name |
the compiler splices the hook body after the declaration and substitutes the receiving class/module for base; this is static expansion, not general runtime hook dispatch |
Inheritance lifecycle hook (def self.inherited(sub), def inherited in class << self, or def inherited in a module the class extends) |
fired once per subclass, before its body runs, for class A < Base with a constant superclass (one built by Struct.new(...) do ... end too), through the nearest ancestor that defines it (a private hook too) |
the compiler places Base.__send__(:inherited, self) first in the subclass's first body; this is static expansion, not runtime hook dispatch. X = Class.new(Base) do ... end fires it with the class already named X (CRuby names it after the hook returns); Class.new(Base) with no block, a superclass held in a variable, and a module extended through anything but a constant, do not fire it |
Methods added to Class (class Class; include M; end, Class.class_eval { include M }, a def in either) |
a class method of every class: Klass.m, String.m, and a bare m in any class body. Refused at compile time, naming the line: Class.class_eval anywhere but a top-level statement, and class Class < .... On a builtin class a class-level @ivar of such a method is one slot shared by every builtin class |
a nested or conditional addition would need the methods to exist only once it has run; a builtin class has no class-ivar storage of its own |
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 |
redo in the block of an iterator whose emitter walks the body itself (a lazy pipeline's stages, chunk_while, transform_values, Array.new, ...) |
refused at compile time, naming the line | redo re-runs the body in place, which needs a label after the block's setup. each, times, upto, map, select, reject, find, flat_map, sum, inject, sort_by, uniq, a comparator for sort, min or max, bsearch, gsub, map.with_index, the in-place filters, map!, fill, product, tap, each_char, each_index, a user yield and the other iterators that go through the shared body emitters place one; each remaining emitter would need the same. Compiled as a continue, it used to leave the block as next does |
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 |
StringIO#readpartial / #sysread / #read_nonblock with a buffer argument |
the data comes back as the result, but the caller's own buffer variable is not changed | the methods are plain Ruby in the stringio package, and a String parameter a package class's method changes is not yet passed by reference the way a user class's is. Through a value that may be a File or a StringIO (def rd(io) = io.readpartial(n) called with both) the three methods raise NoMethodError: that receiver takes the built-in IO arms, which don't see a package's plain-Ruby methods |
IO::Buffer |
the full in-memory API, CRuby-faithful: new (INTERNAL/MAPPED flags), get_value/set_value/get_values/set_values over all 18 type symbols (little/big-endian, u64 round-trips Bignums under --int-overflow=promote), get_string/set_string (NUL-safe binary), resize/clear/copy/size, slice (live views, safe across a source resize), transfer/free/dup, <=>/==, hexdump/inspect/to_s, the predicates, the tiling bitwise family (& | ^ ~ and and!/or!/xor!/not!), locked, IO::Buffer.for(string)/.string/.size_of, the CRuby exception classes (IO::Buffer::AccessError etc.), and the IO integration: #read/#write/#pread/#pwrite against an IO (one syscall each, answering the count, 0 at EOF or -errno; a blocking read on a socket or pipe parks the green thread, and the buffer is locked for the duration) and IO::Buffer.map(file, size, offset, flags) as an mmap view (READONLY / SHARED / PRIVATE; munmap'd by the finalizer; resize refused as for EXTERNAL). Passed to an ffi_func pointer argument, a buffer hands C its base address for the call (FFI.md). No require needed, as in CRuby. A literal type symbol compiles to a direct typed accessor (the wasm-runtime / binary-protocol hot path) |
#read serves the bytes the IO's own stream already buffered before reading the descriptor (a getc followed by a read sees the next bytes); CRuby's reads the descriptor directly and can skip what its buffer holds. Three more deliberate divergences: IO::Buffer.for(string) copies (Spinel strings are immutable, so unobservable) and its write-through BLOCK form raises NotImplementedError; each/each_byte/values are block-form only (no Enumerator, as with StringIO); get_string's third (encoding) argument is not accepted |
The value of super in initialize (c = super, super.frozen?, c = if f then super else [] end) when the parent's initialize is the program's own |
refused at compile time, naming the line | an initialize is compiled to return nothing, since new drops its value; one whose value a subclass's super asks for would return its last value instead |
Array#hash (and arrays as hash keys) |
unsupported | a builtin is additive, but array keys need the fundamental key-dispatch above |
IO.popen |
refused at compile time, naming it | the bundled open3 package's Open3.capture2 / capture3 (with stdin_data:) and Process.spawn with pipes cover what it is used for; the method itself is a stream held open over a child, which the open3 package does not model yet |
| Sockets | TCP / UDP / UNIX-domain, as IO handles -- see below | additive: each missing class and method is its own runtime binding |
| Passing data through a named pipe (FIFO) between two threads, on macOS | the reader gets nothing and the program hangs; Linux answers what CRuby answers | not the open, which is what #4394 was about, and not any change since: a reader and a writer exchanging three lines through one mkfifo path fails on macOS against a tree with no runtime change at all (#4406), so it is the readiness path a FIFO descriptor reaches once both ends exist. A pipe (IO.pipe) or a UNIX-domain socket carries the same traffic and works on both. Opening a FIFO no longer stalls the other green threads on either platform |
class LoadError / class NameError / class Exception … reopenings of a builtin exception class (activesupport's core_ext/load_error.rb, core_ext/name_error.rb, Exception#as_json) |
supported | the class stays the runtime's: raise LoadError, msg, LoadError.new(msg), rescue LoadError => e, is_a? and e.class behave as before the reopening (they used to build a shadowing user class, so raise LoadError, msg was a TypeError). The added methods take the runtime exception as self and are reached on a rescued or constructed exception and on a user subclass's instances; a bare message / key / name / path inside one is the exception's own. When several reopenings define one name (Exception#brief and KeyError#brief), a base-typed receiver is told apart by its runtime class, most-derived first in declaration order; a poly (run-time-typed) receiver does not see these methods yet |
Fiber.new(storage: hash) / Fiber#storage= with a Hash that has a default |
the default is dropped: Fiber[:missing] reads nil |
the fiber keeps the Hash's entries, not the Hash itself, so its default / default_proc don't come along; CRuby keeps the Hash |
A fiber scheduler (Fiber.set_scheduler, Fiber.schedule, the Fiber::Scheduler hooks) |
not supported: Fiber.scheduler and Fiber.current_scheduler are always nil |
blocking IO, sleep and the thread primitives park the green thread on Spinel's own scheduler instead (see thread.md); nothing routes them to a Ruby scheduler object yet. Fiber#blocking?, Fiber.blocking?, Fiber.blocking { } and Fiber.new(blocking:) work, and answer what CRuby answers with no scheduler set |
k.new(x) where k is a Class VALUE and the constructor parameter is typed by its DEFAULT |
NoMethodError where CRuby constructs |
initialize(a = 1) types a Integer. A statically known Klass.new("x") widens that parameter, because the inference can see the call site and which class it names; a class-value call site names no class, so it seeds nothing and the parameter keeps the type its default gave it. The dispatch then has no arm for a String argument and raises. Passing an argument of the parameter's own type works, as does any constructor whose parameters are typed by their uses rather than by a default. Before this raise existed the arm was selected anyway and the argument's bits were read as the parameter's type, so the raise is the fix rather than the limitation |
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 |
A callable boxed into a poly container ([obj.method(:m)][0].call(x)) is
dispatched at run time, so the call site cannot see the target's C signature.
Spinel stamps that signature on the Method at its statically known bind site
(a legacy sp_int register ABI, or under --int-overflow=promote the boxed
poly ABI) and the poly path calls it through that stamp when the call fits
it. When it does not, the call takes the thunk the bind site synthesized
for the target: a per-target C function that reads the boxed arguments,
converts each to the parameter's C type, fills an omitted optional from its
default, packs a rest, calls the target with its real signature and boxes the
result. So a Float parameter or return, a call below full arity, a rest
parameter and a mixed promote signature all answer as CRuby does; a count the
signature cannot bind is CRuby's ArgumentError. What is left:
- The thunk converts at the boundary, by the parameter's compiled type. An
argument of another kind -- a Float into a parameter the analyzer typed
Integerbecause every visible call passed one, a String into a Float parameter -- raisesTypeError(wrong argument type Float (expected Integer)) there, where CRuby would run the body with it (and usually fail inside it). A parameter whose type the analyzer could not see at all isIntegerby default, so a method called ONLY through a Method object takes Integer arguments; give it one visible call with the intended kinds. - A bound builtin's
__bam_wrapper has no thunk: it keeps the stamped ABIs and declines withNoMethodErroroutside them ([method(:puts)][0].call(1, 2)). The proc ABI packs at most 16 positional slots, soMethod#to_procof a target with more than 16 positional parameters is not supported. - A typed-array adapter value the typed array cannot hold (
arr.method(:push)given a String,arr.method(:[]=)given a String value) raises the sameTypeErrorevery typed-array store raises for such a value (see "A typed array holds one kind of element" below). CRuby's Array is heterogeneous and would accept it; the typed array is what cannot. Through a poly slot ([arr.method(:push)][0].call("z")) the call is an ABI mismatch and declines withNoMethodErrorbefore any value is examined. An out-of-kind INDEX still raises CRuby'sTypeError, and a zero-argumentarr.method(:[]).call()raisesArgumentErrorrather than reading index 0. A zero-argumentarr.method(:[]).to_proc.call()raises the sameArgumentErrorthrough the proc trampoline; the unmodeled two-argument slice form (arr.method(:[]).to_proc.call(0, 2)) still answersarr[0]where CRuby answers[arr[0], arr[1]]. The value refusals can mutate before they raise:arr.method(:push).call(3, "z")appends3and then refuses"z", so a rescuedTypeErrorleaves the array partially pushed (CRuby's untyped Array would have appended both). - Over-arity is not modeled: a bound array operator whose wrapper/adapter C
cast has a fixed operand count ignores operands past the ones it models.
ia.method(:[]).call(0, 2, 3)answersia[0]where CRuby raisesArgumentError, andia.method(:[]=).call(0, 9, 8)sets index 0 where CRuby performs the slice assignment. The in-range slice forms (two arguments to[], three to[]=) are accepted and ignored the same way. A multi-valuepushthrough a poly slot ([ia.method(:push)][0].call(8, 9)) declines withNoMethodErrorwhere the static route pushes both values.
A parameter default that WRITES a local the method body READS -- the
def index_with(default = (no_default = true)) idiom, or
def m(a, c = (z = a + 1; z)); z; end -- is run by the callee rather than at
the call site, where the write could not reach the body: the parameter's
default becomes the private symbol :__sp_absent, the locals are declared
nil ahead of the body, and a guard binds the parameter from the original
default when it sees the symbol. The parameter's inferred type therefore
includes Symbol (a scalar parameter widens to a boxed one), and a caller
passing that very symbol is taken as omitting the argument.
A module method's optional parameter default is typed in the MODULE's own
scope, which cannot see the including class's instance-variable types. A
default reading such an ivar is therefore coerced into the parameter's
module-inferred slot: module M; def m(a, b = @o); [a, b]; end; end included
by a class with a String @o answers [1, 0], not [1, "hi"], and
def m(a, b = @f + a) with a Float @f answers [1, 2], not [1, 2.5].
The bound-Method routes box the field correctly; the loss is the parameter's
inferred Integer width, shared with the ordinary direct call.
A typed-array adapter Method (arr.method(:push)) reports the CRuby arity of
the Array op it stands in for (-1) except for [], whose adapter Method still
reports 1 through a poly slot where CRuby answers -1.
An Exception subclass instance held in a genuinely poly value -- read out of
a heterogeneous container, or returned through a poly Proc -- dispatches
#class and #inspect, but #message raises NoMethodError and #to_s falls
back to #<MyErr:0x...> instead of the message, where CRuby answers the message
from both:
class MyErr < StandardError; end
arr = [MyErr.new("boom"), 5]
e = arr[0]
e.class # => MyErr (as CRuby)
e.inspect # => #<MyErr: boom> (as CRuby)
e.message # => NoMethodError (CRuby: "boom")
e.to_s # => #<MyErr:0x...> (CRuby: "boom")The class and message are carried on the boxed value, but the #message/#to_s
arm is only emitted for a receiver whose static type names the exception class.
The behavior predates and is independent of the bound-Method work above: it
reproduces through a poly Proc result as well.
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),
Socket.sockaddr_in(port, host) / .pack_sockaddr_in,
Socket.sockaddr_un(path) / .pack_sockaddr_un, Socket.unpack_sockaddr_in.
Addrinfo. Addrinfo.tcp / .udp / .ip / .unix, and #ip_address,
#ip_port, #afamily, #pfamily, #socktype, #protocol, #unix_path,
#ipv4?, #ipv6?, #unix?, #ip?, #to_sockaddr, #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,#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 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.) -
String#then/#yield_selfwith a callable block -- a non-literal&procis refused in both builds: the builtin emitter cannot invoke it while preserving its returned String's identity. Use a literal block. Literal blocks, symbol-to-proc forms that desugar to one, and user-owned methods still compile. -
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. -
nilmeeting a String is a nullable String, not untyped. Where a value can be either nil or a String -- a ternary, anifwith no else, areturn nil if ...ahead of a String, acasewith no matching arm, a local written nil on one path and a String on another, anext nilin a block -- the slot stays a String whose C form carries nil as NULL, the same representationv&.upcase, an ivar written nil and aString?seed already use, and every String consumer reads it as nil (nil?, truth,to_s, interpolation, boxing, andNoMethodErrorfrom the rest). It used to widen to untyped, andreturn nil if v.nil?at the head of a method was the largest single source of the boxed slow path in a real tree. A literalnilinside an array or hash literal keeps the container boxed, as before.nilmeeting an Integer, Float or bool still widens to untyped: the nullable Integer and Float slots that exist (an ivar written nil, anInteger?seed, a container read that can miss) carry their nil beside the value --sp_oint/sp_ofloat, the machine word and a flag -- so every bit pattern of the word is a number, -2**63 and a NaN of any payload included, and a comparison on one raises as CRuby does (nil > 0is NoMethodError,1 > nilthe Comparable ArgumentError,nil <=> 1nil), as does one reaching a strict Integer argument -- an index, a count, a width -- wheres[s.index("z")]is the sameno implicit conversion from nil to integerCRuby raises rather than a read off the front of the string. A Range endpoint is the exception it is in CRuby:s[ix..]on a missedindexis the beginless Range, not an error. But bool and Symbol have no spare value at all. -
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 aMutexrace, similar toArray/Hashin JRuby andArrayin TruffleRuby. What that costs differs by kind of state, anddocs/thread.mdsays which: an object never loses an ivar and a word-sized ivar never tears, a multi-word ivar (Range,Time,Complex,Rational) can be read half from one write and half from another, and a sharedArray/Hashcan abort or SIGSEGV. 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 container walk that meets an object it is already inside stops there and
calls the pair equal, which is what makes a = []; a << a; a == a terminate
at all. For Arrays, Hashes, Structs and plain objects that agrees with CRuby.
For two distinct Sets that each contain themselves it does not: Spinel
answers true where CRuby answers false, and everything reaching == or
eql? follows -- include?, subset?, superset?, intersect? answer
true, disjoint? false, <=> 0 rather than nil, and a Hash keyed by one
finds the other, as do Array#uniq, Array#- and Array#include?.
CRuby's Set is a hash table, and an element's hash is stored when it is added
-- while that Set is still empty -- so its later membership probe misses.
Spinel's Set is Array-backed with a linear eql? scan and has no stored
per-element hash to miss with. The same Set compared with itself, and every
non-recursive Set, agree with CRuby.
The Set package reaches the runtime's recursion path through bindings in a
nested Set::RecursionGuard module, so Set.constants lists it and
Set::RecursionGuard.respond_to?(:enter_eq) is true where CRuby raises
NameError. It is not an API and nothing else about Set's surface changes;
Set.constants already differed from CRuby's, which lists CoreSet.
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 carries that nil out of band: the
read answers the machine word plus a nil flag (sp_oint; a Float read,
sp_ofloat), and an Integer or Float array that holds nils keeps a bitmap
beside its words. The value is nil for nil?, inspect, class and
||, and every consumer that can see it raises the way CRuby's nil does:
arithmetic (+, -, *, /, %, abs) is NoMethodError or the
coercion TypeError, comparisons and the numeric predicates (<, >,
<=>, zero?, positive?, ...) are NoMethodError or the Comparable
ArgumentError (#4567), and a strict Integer argument -- an index, a count,
a width -- is no implicit conversion from nil to integer (#4896). The flag
is carried only where the analysis says the value can be nil, so a loop
counting from a literal keeps its bare compare and its bare index, and a
plain Integer slot is a bare word with no test at all.
No bit pattern of the word means nil. An Integer that is exactly -2**63 --
-9223372036854775807 - 1 computed without overflowing, ~0x7fffffffffffffff,
a wrapping + - * or 1 << 63 under --int-overflow=wrap, a q unpack,
an IO::Buffer :s64 read -- is that Integer in every slot (a local, a
parameter, a return, an ivar, an array element, a Hash value or key) and in
every --int-overflow mode, printed, tested for nil or truthiness and
computed on as CRuby does; so is a Float NaN of any payload. The price is
the flag beside the word: two registers for a nullable parameter or return,
one bit per nullable scalar ivar, the bitmap on an array that holds nils.
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_intA Range of Strings holds its two endpoints by value, as copies. A change in
place to the String an endpoint was made from (<<, a ! method) is not
seen through begin, end, first or last, in the default build and
under --share-strings alike:
first = String.new("aa")
r = (first.."zz")
first << "x"
p r.begin # "aax" in CRuby, "aa" in SpinelSharing it needs a Range whose endpoints are shared String handles; that is
part of the --share-strings work (#7721).
Spinel 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 Array whose every visible element is an Integer, a Float or a String is
compiled as a typed array (sp_IntArray, sp_FloatArray, sp_StrArray),
which is what makes numeric code fast. A store the compiler can see both
sides of widens the array instead (a = Array.new(0, 0); a << "x" makes a a
general Array), so the typed representation is only kept where every store
agrees. A value whose kind is decided at run time -- an element read out of a
general Array, a boxed parameter, a poly-typed call -- that does not match
the array's kind cannot be stored, and raises TypeError at the store:
def collect(out, src)
i = 0
while i < src.length
out << src[i] # src[i] is decided at run time
i += 1
end
end
a = Array.new(0, 0)
collect(a, [1, "z"]) # TypeError: cannot store String into an Array[Integer]: a typed array holds one kind of elementCRuby's Array would hold the String. Spinel used to coerce instead ("z".to_i
into an Integer array stored 0, an Integer into a String array stored ""),
which was a wrong value said nothing about; refusing is the one answer that
never lies about what the array holds. nil is the kind's own nil and is
stored; an Integer stored into a Float array is promoted, as CRuby's
arithmetic would promote it. Every store route answers the same way: <<,
push, unshift, insert, concat, fill, []=, the runtime dispatch on
a boxed array, and a typed-array Method adapter. A nested store through a
general container (grid[r][c] = v where the row is typed) is the one route
that widens the row to a general Array instead, since the container is what
holds it.
A parameter the method stores elements of another kind into is compiled as a
general Array (or a boxed value, when its callers disagree), and a typed
array (Array[Integer]) passed to it has to be one too, since the two store
their elements differently. The compiler follows the argument back to where
its arrays are built and builds them as general Arrays: an array literal or a
new array, through locals (their ||= and the arms of a branch too), the
value of a method (or a chain of them, a method a subclass overrides, or a
method answering its block's value), a then block, a multiple assignment
from a literal, a row of a literal table, a builtin that answers its receiver
(push, concat, tap), an ivar or attr_reader every write of which builds
a new array or keeps a parameter of the method writing it, a global, class
variable or constant. The method's other callers then read the general Array
too. A boxed argument -- a local written arrays of two kinds, an element read
out of a general container, a block parameter, a for variable -- is
followed the same way, to each typed array the store cannot fit.
Where it cannot follow a typed argument, the one conversion left is a copy, and the mutation would land in the copy where CRuby changes the caller's array. Such a call is refused at compile time:
Box = Struct.new(:a) # the member keeps the caller's array
def add(out) = out << "z"
src = [1, 2]
add(Box.new(src).a)
# spinel: t.rb:4: an Array[Integer] is passed to `add`'s parameter `out`, which the method mutates: ...What it does not follow: a Struct member, or an ivar an attr_writer writes,
that keeps an array handed in from outside; an array an rbs seed declares
typed (a parameter or a return); a call through a receiver of no one class;
the value of a lambda or proc; a new array (Array.new(2, 0)) a multiple
assignment binds. Build the array as a general Array where it is created, or
give the parameter the argument's kind (an rbs seed, or call sites that all
pass the same kind).
With --share-strings, ENV.store and ENV.[]= return their value
argument's handle while the environment keeps a copy. A shared String
stored through a boxed value is refused when another live name could
observe a copy of its plain bytes. Fresh results and fresh values passed
at a local's only read are supported. The result route also refuses a
mutable key whose handle cannot be held while value evaluation can change
it; a plain value read does not require this refusal. A shared String
returned by an ENV.delete block is refused when the result route cannot
carry that handle. Delete's block parameter is the String key, and a block
that returns its own new String remains supported. ENV.fetch preserves
a default's available String handle. The snapshot
routes for ENV.assoc and ENV.rassoc likewise refuse an observable copy
of the supplied key or value.
A local, ivar, Hash value or Array element that holds more than one kind of
value can carry a String two ways. As a shared handle (where a String in it
is changed in place elsewhere in the program) it behaves as in CRuby. As a
plain boxed String, << builds the longer String instead of changing the
old one. A << (or a chain of them) on a local, or on an ivar inside a
method, stores the result back into that slot, whether its value is used or
not, so the slot is right; but another name for the same String does not see
the change. (An ivar a class body writes, outside any method, does not take
the result back at all yet.)
h = {}
[1].each { |i| h[i] = "v#{i}" }
h[2] = 5 # the values are now Strings and Integers
o = +h[1]
o << "+"
p o # "v1+" in both
p h # {1 => "v1+", 2 => 5} in CRuby, {1 => "v1", 2 => 5} in SpinelA method that appends to its String parameter (s << x, concat,
insert, a ! method) changes the caller's String in CRuby, since both
names hold the one object. Spinel shares the String by reference through a
direct call, send with a literal name, super, a poly receiver and a
class value; through a proc, a lambda and a Method (.call, .(),
[], === and .yield on one, a block kept as &blk and called later,
method(:m) and obj.method(:m) and their to_proc, whether the String
is passed by position or by keyword); through yield, into a literal
block, a block the method keeps, and a proc or Method passed with &;
through instance_exec, instance_eval, class_exec and module_exec;
through new and raise Cls, s into an initialize, one that yields the
String to its block, a Struct's and a Data's included; and through a splat
or a gather, into any of those (m(*args, s), m(*[s]), C.new(*[s, s]),
def m(*r)). The paths below do not share it yet, and a call that would
hand such a method a String variable through one of them is refused at
compile time rather than compiled with the append lost:
f = ->(t) { t << "!" }
["a", "b"].map(&:dup).each { |s| f.call(s) }
# spinel: t.rb:2: a String is passed to a proc's parameter `t` through `f.call`, which the proc appends to: ...It is shared as well through an UnboundMethod (bind_call,
instance_method(:m).bind(o).call), a method define_method defines, a
curried proc, and a proc or Method read out of a slot that holds other
values too.
With --share-strings, loading a native class such as StringIO does not
by itself invalidate a fresh String returned by a user method through a
boxed receiver. Native candidate entries with no Ruby method, reader, or
native binding for the called name or method_missing add no return path.
Real native bindings and readers remain conservative, and the existing
user-target and dynamic-hook checks still apply.
With --share-strings, a method that returns a fresh empty Hash directly
({} or a blockless Hash.new with no default or a String/nil default) can
carry shared String values even when inference otherwise gives the return a
String-keyed, String-valued type. The promotion preserves the Hash and stored
String identities through callers and ivars. RBS-pinned returns and methods
with nonempty or alternate Hash producers keep their existing inferred types.
With --share-strings, a boxed call may also compose an exact String builtin
row with same-named user-method returns when the settled dispatch plan accounts
for both. For example, a blockless two-argument String#sub can return a fresh
String through its String arm while a same-named user method returns nil;
matched and unmatched substitutions both keep their String result separate
from the receiver. A builtin row's argument peeks also cannot discard values
retained by its same-named user arm. The analysis still joins every reachable
user return, so a user arm that returns an existing String retains that alias
and any required mutation refusal. Ambiguous builtin or dynamic dispatch
remains conservative. If the program declares singleton readers or writers,
this composition precision is also disabled: a user method can reach class-side
publication through a helper even when that accessor is absent from the call's
dispatch plan, and those holder edges are not fully represented here. Such
programs retain the previous boxed-container effects.
Not yet shared:
-
String
selfhanded to a mutating block throughyieldorblock.callin the default build. Build with--share-stringsto keep receiver mutations through this route. Read-only blocks and fresh String returns work in both builds; -
a String variable in a splatted Hash literal (
**{ k: v }) at a dynamic call or yield whose key binds an appending keyword parameter; -
a String variable in an Array literal feeding an appended nested multiple-assignment target;
-
a bare instance-variable argument written from a local, handed to an appending parameter through a call or
super, unless the instance variable is already a shared handle; -
a repeated keyword whose later value is a String variable bound to an appending parameter, unless the value is already passed as a shared handle;
-
through
Thread.neworFiber#resume, a String variable handed to a block parameter that appends to it, unless its read already hands over the shared handle or the local is read only as that argument; -
through a Hash's value block (
each_value,each,each_pair, or an element iterator overvalues,values_atorfetch_values), a stored String variable when the value parameter appends to it; -
through a Hash's
[key, value]pairs (h.to_a,h.first,h.min_by { },k, v = h.first, an iterator over them), a String value that is then mutated; -
through
yieldinto a capture-wrapper block, a String variable whose captured parameter appends to it without already being the shared handle, including a splatted yield; -
through an Array's chained index into an appending block;
-
through a retained
scrub!result that is appended to;scrub!with a block is also refused because the block would be ignored; -
through a container element, a String a boxed local holds (
s = [+"xy", 1][k]) stored into an Array, a Hash, an instance variable's or a global's Array and mutated in place through an element read or an iterator's block parameter ([s][0].prepend(x),[s].each { |e| e << x }); -
through a literal's element, a local bound from an element read of an Array or Hash literal holding a String variable (
t = [s][0],t = [s].first), when the local is mutated in place and the variable is read again; -
through a global variable: a String variable assigned from a global (
t = $g,t = $g.to_s) or to one ($g = s,$h = $g), when one name is mutated in place and the other is read; -
through a method that returns its String parameter (
def id(x) = x, alsox.itselfor a bang method on it), when the result or the variable passed in is mutated in place and the other is read (a variable passed in through the caller's own parameter counts: the caller reads it again). A reopened String method returning an omitted parameter whose default isselfhands on a copy of the receiver in the default build and is not refused (--share-stringsshares it); -
through a bang method's result, which is its receiver (
r = s.strip!,r = s.strip! || s), when the result is mutated in place through a variable and the receiver is read, by its own name or, for a local that shares its String, through the other one (t = s; r = t.strip!); a mutator straight on the result (s.sub!(a, b) << x) reaches the receiver; -
through the result of
bytesplice,append_as_bytes, orconcatorprependwith other than one argument, which is its receiver too (r = s.concat(a, b)), when the result is mutated in place through a local, a global or an instance variable of the top level and the receiver is read, or when a method returns its parameter through one of them. A class's instance variable that keeps the result is left alone: an append through it reaches an instance-variable receiver in some programs (@r = @s.concat(a, b)where a parameter set@s) and does not build in others, and most other changes through either name are lost. A mutator straight on the result ofbytespliceorappend_as_bytes(s.bytesplice(0, 1, "Z") << x) does not reach the receiver. Neither of these is refused yet; -
through
to_s(or another call answering its String receiver) under a mutator whose argument reassigns the variable, when the variable is a String handle (e.to_s << (e = +"b")); -
into an Array, a String variable added by
insert,prepend, aconcatof a literal, the later push of a chain (a << t << s) or a push into a global Array, or as the value ofs << x, of+s(s itself unless s is frozen; a parameter is refused even when every caller passes a frozen literal) or of a reader method (a << sbwithdef sb = @s), when the variable or the element is then mutated in place; -
into an Array or a Hash, the result of a method that returns its String parameter, stored straight (
a << id(s),a[0] = id(s),a = [id(s)],h = { k: id(s) }), when the variable is mutated in place and the container is read, or an element is mutated in place and the variable is read. A constructor argument (Box.new(id(s))) and a literal that is not assigned to a variable are still copies and are not refused; -
through a reader on a boxed receiver (a Struct or Data member, an
attr_reader,def m = @iv), a String read into a local that is mutated in place while the receiver is read again (a local only queried with String's own methods and then rebound to a copy with String's owndup,s = s.dup, before the change compiles: the change is the copy's); -
into a container, a String held by a block parameter no element iterator binds (a proc's, a lambda's, a yielding method's block,
each_char's,scan's), when an element is then mutated in place; -
through
scan's block parameter, a match the block keeps and mutates in place (it did not build); -
through a global variable's Array, a String element mutated in place through the Array (
$b.each { |y| y << x },$b[0] << x); -
into an Array or Hash a caller passes a method, a String the method stores (
def keep(a) = (a << n)), when the caller mutates the element in place; -
through an ivar's or a call's Array, a fresh Array literal, a narrowed boxed String element, or a fresh String's
tap, into an appending block or parameter; -
through a parameter of a reopened builtin's method that the method yields on or passes to its
&block(class String; def po(a) = yield(a); end, thens.po(u) { |v| v << x }), into an appending block: the block appends to a copy in the default build, and this is not refused (--share-stringsshares it); -
by keyword, through a curried proc;
-
through
instance_exec, a String variable in or ahead of a splat (o.instance_exec(s, *rest) { |t, *r| t << "!" }), and one held by a block parameter, by a variable a proc captures, or by a global or class variable; through ayieldinto a block the method keeps or a proc passed with&, one held by a block parameter or by a global or class variable; -
through a splat of a local Array the program changes after its literal, a String it holds that is no shared handle: a global or an instance variable pushed into it, or the contents of another Array (
s.replace(t)); -
through a proc, a
Method(bound, unbound, or read out of a slot), a curried proc, a methoddefine_methoddefines,neworraise, a String held by a block parameter, by a variable a block or proc captures, or by a global or class variable, and through a proc, aMethodor a class value'snew, one held by an instance variable; -
through a
Methodbound to one of the String's own in-place mutators (s.method(:<<),s.method(:concat),s.method(:upcase!), theirto_procand&s.method(:<<)): the Method is bound to the String's value, so.methoditself is refused, naming the line. Call the mutator on the String, or wrap it in a block (->(x) { s << x }).
A String is shared as well through a rest a method forwards (def w(*a) = m(*a), def w(*) = m(*), def w(...) = m(...), def m(*) = super) and
through a parameter a method hands on to super or a call after a **h
call typed it POLY; through those, one held by a block parameter, a
variable a proc captures, an instance variable that is no shared String,
or a global or class variable is refused. A String variable in or past a
splat into super or yield (super(*s), yield(*e, v), a rest yielded
as yield(*r)), bound to a parameter that appends, is refused as well, and
so is any String forwarded to the 17th position or past it, or handed on
through more POLY parameters than the analysis follows.
Each is lifted in turn, and this list shrinks with it. Until then, return the String from the method and assign it, or append to it in the caller. A literal or any other expression passed there is not refused: nothing else can see its growth.
The block of a lazy stage ([s].lazy.map { |x| x << "!" }, and select,
take_while and the other stages up to the first map, past a with_index
too) is handed a boxed
copy of the element, so a block that changes its String element in place
is refused as well, naming the line. Drop the .lazy (the eager form
shares the String), or return a new String (x + "!").
Assigning to a method's &block parameter used to be refused where it was
written. It now compiles as the rewrite that used to be asked for, done by
the compiler: the parameter's value moves to a fresh local at the top of the
body, the writes and later reads use that local, and yield and
block_given? keep seeing the block the caller passed, which is CRuby's
split too.
def fetch(key, &blk)
blk ||= proc { |k| raise KeyError, k } # works
blk.call(key)
end
def dispatch(*args, &block) # activesupport's BroadcastLogger
if block_given?
called, result = false, nil
block = proc { |*a| called ? result : (called = true; result = yield(*a)) }
end
targets.map { |t| t.send(:log, *args, &block) }
endA yield inside a proc or lambda literal is the other half of that
shape. The literal is a real closure, its own C function, with no inlined
caller's block to reach, so the yield raised LocalJumpError at run time. It
now calls the method's block parameter as a value, blk.call(args), and a
def with no named block parameter is given &__blk for the purpose. A
block attached to any other call (each { yield }) is inlined with its
method and keeps its yield.
A method whose block use is block_given? plus a value use of the parameter
(x = blk, a capture in a proc, a &blk forward) takes the same lowering a
method with a literal yield and a value use does: it is emitted as one
function with the block as a real parameter rather than inlined per call
site.
Array[Array[Integer]], Array[Array[Float]] and an Array of one class's
objects are compiled to an unboxed pointer array when every use supports it
(see docs/rbs-extract.md). Reaching a slot that holds any kind of value -- a
method whose value is the table on one path and nil on another, an element
of a general Array, a Hash value, a boxed parameter -- boxes the array by
reference, stamped with what its elements are, so the boxed value is the same
array (a mutation through either side is seen by both) and reads, inspect,
==, iteration and the rest answer as they would for a general Array:
def run(flag)
run_bcf if flag # the table on one path, nil on the other
end
p run(true) # [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]The typed-array rule above applies through the box: a store of another kind
(t << "x", t.push(1), t.insert(0, :s), t.concat(["q"])) raises
TypeError, where CRuby's Array would take the element. An object array of
one class takes that class and its subclasses.
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 range literal that is only
matched (when ..2.5, (1.5..) === x, .cover?(x)) takes the Float
representation instead, so it compares its bound as written.
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 or
-DRE_NO_UNICODE_CTYPE (make RE_CASE_FLAGS=-DRE_NO_UNICODE_CASE) is mruby's
MRB_USE_ASCII_CTYPE build: it leaves the case tables and the type table out
together, folds ASCII alone, and a non-ASCII literal then matches literally
under /i too.
A pattern may have at most 31 capture groups. The match registers $~
and $1..$9 are built from hold that many, and so does the frame that saves
them across a call to a method that matches, so a wider pattern is refused
with too many capture groups (maximum 31) rather than compiled and then
truncated to what fits. CRuby has no ceiling here. 31 is where the registers
sit rather than where a program is likely to need to stop: of the 8,135 regexp
literals in CRuby 4.0.4's stdlib and bundled gems, the widest has 8 capture
groups. (?:...) costs nothing against it, and a named group counts as one.
The refusal is at compile time for a literal and at run time for a pattern
built there.
A regexp literal is compiled when the program is. A pattern the engine
cannot read is refused at compile time rather than raising RegexpError when
the built program reaches the literal, which is where CRuby reports it too
(as a SyntaxError from the parse). A pattern built at run time -- an
interpolated literal, Regexp.new on anything but a constant -- is still a
runtime question and still raises RegexpError.
A search that backtracks is bounded by the state it holds. A pattern with
a backreference, a lookaround or an atomic group runs on the backtracking
engine, whose choice points and undo records are capped together by
MRB_REGEXP_STACK_LIMIT (32768 entries). A greedy repetition leaves one
choice point per iteration, so what a search holds grows with the length of
the subject: "a" * n + "b" + "a" * n =~ /\A(a+)b\1\z/ is answered for n up
to roughly 30000 and gives up above it, where CRuby keeps going. Giving up
raises RegexpError (stack limit over (MRB_REGEXP_STACK_LIMIT)) rather than
answering nil: a search stopped at the limit has not shown that there is no
match, and a nil there would be a wrong answer whenever the match lay past
it. The step ceiling (MRB_REGEXP_STEP_LIMIT) bounds the catastrophic shapes
the same way, so ("a" * 40 + "!") =~ /(a*)*b\1/ raises in milliseconds
rather than running for years.
A POSIX bracket and a word boundary read Unicode above ASCII.
[[:alpha:]] and its ten siblings hold what CRuby's brackets hold in every
script, and \b / \B sit beside a character of any script, both read off
the type table in lib/regexp/re_ctype.h. \d, \w and \s are ASCII in
Ruby's syntax and stay so, exactly as in CRuby, so /\w/ and /\b/ answer
different questions about the same character on purpose. The ASCII build
(see the fold note above) leaves the type table out, and a bracket then holds
its ASCII set alone, which the boundary reads too.
Unicode properties are the POSIX names, the general categories and the
emoji properties. \p{name} holds the characters with the property, and
\P{name} or \p{^name} the ones without it, inside a class or outside one.
The POSIX names (Alpha, Alnum, Word, Space, Upper, Lower, Digit,
Punct, Graph, Print, Blank, Cntrl, XDigit, ASCII) are the brackets
under another spelling, /i included, except that \p{Punct} leaves out the
nine ASCII symbols $, +, <, =, >, ^, `, | and ~ that
[[:punct:]] holds, as CRuby does. The
general categories are the thirty two-letter ones (\p{Lu} .. \p{Cn}) and
the first letter alone for a group (\p{L}, \p{M}, \p{N}, \p{P},
\p{S}, \p{Z}, \p{C}); the emoji properties are Emoji,
Emoji_Presentation and Extended_Pictographic. Names match as CRuby
matches them: case, _, - and spaces make no difference. Under /i a
property folds as the class of its members would (/\p{Lu}/i holds "e"), and
a negated one follows CRuby: \P{Lu} under /i holds no cased letter, while
[\P{Lu}] holds them all. A script (\p{Han}), a binary property
(\p{Alphabetic}), an age, a block and every other name raise RegexpError
naming the property, and a property cannot end a range ([a-\p{L}]). The
tables come from the Unicode 17.0.0 database (lib/regexp/re_prop.h, about
18KB); the ASCII build (see the fold note above) leaves them out, answers the
POSIX names from ASCII as its brackets do, and refuses the categories and the
emoji properties rather than answer them from ASCII.
A regexp construct the engine does not carry is refused, not read as its
letters. \K (drop what was matched before it), \R (any linebreak) and
\X (a grapheme cluster) each mean something in CRuby that this engine does
not do. Left as unknown escapes each was simply its own letter, so /\R/
matched an R rather than a newline. They raise RegexpError at compile time
instead. Inside a character class CRuby reads \K, \R and \X as the
letter too, and so does the class parser here, so [\R] still matches an R.
\G and \g<name> ARE carried and behave as CRuby does.
The same applies inside a character class, where a [ never stands for
itself: [[.a.]] (a collating element) and [[=a=]] (an equivalence class)
each raise RegexpError rather than compile a different pattern than the one
written. A nested class is read as CRuby reads it, the union of its members
([[a][b]] is [ab]), and so are [[:alpha:]] and &&; [\[] holds the
bracket itself as it does in CRuby.
A byte escape that starts no character is that byte. The engine has no
encodings: /[\x80]/ matches the byte 0x80, where CRuby refuses a UTF-8
pattern with invalid multibyte escape. It reads the same byte inside a
nested class or a && intersection, where CRuby answers differently again.
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: equal frozen literals are one object, as in CRuby
with frozen string literals. "abc".equal?("abc") is true, the same
text in two methods (or in two parts of a --jobs=N split build) is the
same object, an adjacent-literal fold ("ab" "c") is the object the plain
"abc" is, and a literal in a loop yields one object on every pass. An
interpolated string ("#{x}") is built anew each time, in CRuby too.
What still differs is the run-time intern table behind String#-@ /
dedup. CRuby puts every frozen literal in it when the code is loaded, so
-("ab" + "c") returns the literal "abc" itself. Spinel's table holds
only what -@ / dedup has been called on: two run-time strings dedup to
one object, and -"abc" returns the literal when the literal is the first
of its content to be deduped, but a run-time string deduped before that is
not the literal ((-("ab" + "c")).equal?("abc") is false), and the
literal's own -@ then returns that earlier object. str.dup.freeze is
never deduplicated, in CRuby either.
Symbol#to_s and #id2name answer a new String on every call in CRuby, so
:abc.to_s.equal?(:abc.to_s) is false. Spinel keeps one chilled String per
symbol and answers it each time, so that is true. The value is the same,
and so is every mutation: the String is chilled, so s = :abc.to_s; t = +s
copies, and s << "x" makes s its own String and leaves the next to_s
alone (:abc.to_s is still "abc"). Only the identity of two to_s results
differs; a new String per call would cost an allocation at every symbol read,
which programs that build names from symbols do in loops.
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 transforms and searches are byte-exact too: the case ops, the strip
family, chomp / chop / delete_prefix / delete_suffix, index /
rindex / include? / start_with? / end_with?, sub / gsub /
tr / delete / squeeze / count, split / partition / lines /
each_line, reverse, succ, sum, a regexp match position, and
padding (ljust / rjust / center, a "\0" pad included).
Interpolation and the format family are byte-exact too since #4632:
"x#{s}y", format / sprintf / String#% (with a width or a
precision as well) carry the NUL and its tail, as do IO#write,
#print, #puts and #pwrite. inspect renders \x00 where CRuby
prints \u0000. test/embedded_nul_method_partition.rb pins which
method is which, and test/format_interp_binary.rb the formatting.
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 -- and compiles: Spinel's generated C type
for a class or module is its bare tail name, which would collide with the
runtime's own sp_Encoding, so the nested definition and every reference to
it are qualified by their module path before the collision can happen, the
way a nested class Array already was. activesupport's
ActiveSupport::JSON::Encoding is the shape. Builtin modules
(Comparable, Kernel, Math, …) reopen normally at any nesting level.
The methods a top-level module Kernel reopening defines are modelled as
top-level defs (Kernel is mixed into Object, so they are bare calls from
every scope), and a Kernel.m(...) naming one drops its receiver, as the
builtin Kernel functions do; activesupport's silence_warnings { ... } is
the shape. With an explicit object receiver (obj.twice(2), legal in
CRuby since Kernel's methods are public) such a method is not reached: the
call compiles and raises NoMethodError at run time.
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.
Constants are resolved at compile time, and every constant the program
defines is known then, so const_get with a name known only at run time
lowers to a dispatch over those names, the way a runtime send lowers over
the program's method names: the arm whose name matches answers the constant,
a class or a value, and a name matching none raises the NameError CRuby
raises (uninitialized constant Carts::Nope, wrong constant name lower).
module Carts
TYPES = { 0 => "A", 1 => "B" }
def self.build(t, x) = const_get(TYPES.fetch(t)).new(x) # works
def self.constantize(name) = Object.const_get(name) # activesupport's shape
endThe result is poly (any constant), so what follows is the boxed-value
surface: .new, .name, ::CONST on a class value all work. The
candidates are the program's constants by their own name in the flat
namespace the literal form resolves in, so a "Outer::Inner" path string is
not matched and raises. The call needs a class or module receiver, explicit
or the implicit self of a class method or class body; in an instance method
an instance has no const_get, and the call is refused where it is written.
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)). instance_variables,
instance_variable_defined?, the default inspect and Marshal.dump read
the same state, and remove_instance_variable clears it.
Spinel's instance variables are C struct fields, pre-filled with their type's
nil representation. A field initialize always assigns before anything can
look at the object is always present, and one every write of which stores a
non-nil value is present exactly when it is not nil. A field neither answers
for keeps an explicit assigned flag beside it, set by each store after its
value is computed and cleared by remove_instance_variable, in the classes
such a read reaches (the receiver's class and those below it, through a
container or another object's ivar for inspect and Marshal.dump). So
these work as in CRuby:
class Foo
def compute = 42
def foo
return @foo if defined?(@foo)
@foo = compute
end
end
f = Foo.new
p f.foo, f.foo # 42, 42
class Bar
def initialize(v) = (@v = v if v)
def has? = defined?(@v)
end
p Bar.new(nil).has? # nilThe flag cannot follow an ivar that is a for loop's or a rescue => @x
target, that a writer reached by name (send(:x=, v)) or a bitwise
op-write through a writer (o.x |= v) assigns, or that a class whose ivars
the compiler writes itself holds; nor any ivar once the program calls
instance_variable_set with a computed name. Such an ivar, and one whose reads reach it only
through a boxed value the analysis cannot bound, is reported as assigned
from the start, as before.
CRuby lists an object's ivars in the order they were first assigned, which
differs between the objects of a class that assigns some of them on some
paths only. In a class such a read reaches, whose ivars initialize does
not all assign unconditionally, every ivar keeps beside it the rank of its
first assignment (a store to an assigned ivar keeps its rank, a removal
clears it and a later assignment ranks it last), and instance_variables,
the default inspect and Marshal.dump list by it; dup and clone copy
the ranks and Marshal.load assigns in the order the stream gives.
A class whose initialize assigns every ivar unconditionally lists them in
the order it does (a super standing for the parent's own, or for an included
module's initialize where the class has one), provided nothing before the
last of those assignments could assign an ivar first: the statements up to it
must be plain ivar assignments, multiple assignments or a super without a
block, whose values and arguments (and the defaults of the optional parameters
and keywords) call nothing of the object's own (no call on self or without a
receiver, no block, yield, super or ivar write), and not inside a begin
with a rescue or ensure. This order also applies to exception classes and
value types, which the ranking leaves out. Otherwise, for a class with an ivar
the flag cannot follow, for a Struct, and in a program that calls allocate or
Marshal.load (their objects run no initialize), the ivars list in the order
the class lays them out, which differs from CRuby's when a method names one
before initialize assigns it, initialize calls a method that assigns one,
or it assigns one conditionally first. A listing through a receiver typed as a
class whose subclasses list other ivars, or the same in another order, lists
as the object's own class does.
compare_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. Equal frozen literals are one
object, as in CRuby (see the identity note under the frozen-string-literal
section): "x".equal?("x") is true, and re-evaluating a literal (one in
a loop) yields the same object. Everything else about identity is
truthful: s.freeze.equal?(s) is true (freeze marks in place), aliasing
compares equal, -str dedups interned content to one object (but not
always to the literal of that content, as noted there), and
distinct-valued strings compare false.
defined? combines compile-time resolution with runtime checks. Local
variables, known constants, implicit method calls, assignments and literals
usually resolve to a fixed label. Global-variable presence is inferred from
writes in the program. Class variables use a runtime assignment flag, and
calls with a receiver can check its value and whether it answers the method.
Instance variables use the static or runtime paths described above.
Examples compared with CRuby, with the referenced constant and method defined
and a block supplied for yield:
| Operand | CRuby | Spinel |
|---|---|---|
Foo::Bar (constant path) |
"constant" |
"constant" |
puts (built-in / Kernel method) |
"method" |
"method" |
obj.meth (public method with a receiver) |
"method" |
"method" |
1 + 1 (operator = method call) |
"method" |
"method" |
{a: 1}, 1..3 (hash/range expressions) |
"expression" |
"expression" |
x = 1 (assignment) |
"assignment" |
"assignment" |
yield |
"yield" |
"yield" |
super in a subclass method with a parent implementation |
"super" |
nil |
| Multi-encoding strings | Strings are UTF-8 or ASCII-8BIT, and one rule says which: a string is ASCII-8BIT when the program ASKED FOR BYTES (pack, String#b, Marshal.dump, Random#bytes, binread, unpack's byte directives, force_encoding naming it). Everything else is UTF-8, including what CRuby calls US-ASCII (see below). A string carries one bit, not an encoding object; #length, #[], #inspect, #encoding and the comparisons read it. A compiled binary's string paths (indexing, regexp, hashing) assume the two share a byte representation, and that assumption is load-bearing for their performance |
write UTF-8; transcode at the boundary before the data enters the program |
The static instance-variable path can report "instance-variable" before a
write on the particular receiver; see the memoization examples above. A class
variable instead reports nil before assignment and "class variable" after
assignment, including when the assigned value is nil.
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.
CRuby has three encodings where spinel has two. A string CRuby generated from
nothing -- 1.to_s, nil.to_s, :sym.to_s, 1.chr, [1, 2].inspect,
Time#to_s -- is US-ASCII; one derived from source text inherits the source's
encoding. Spinel calls both UTF-8.
The reason this costs nothing is that US-ASCII carries exactly one fact, "these
bytes are 7-bit", and nothing else. It is not needed for compatibility:
rb_enc_compatible keys on the CONTENT being 7-bit, not on the encoding's
identity, so an ASCII-only UTF-8 string concatenates with a Shift_JIS one
exactly as a US-ASCII string does. Across the operations that can tell two
same-byte strings apart -- ==, eql?, <=>, hash, Hash keys, length,
[], chars, bytes, upcase, include?, index, sub, split, regexp
matching, to_sym, ascii_only?, valid_encoding?, encode, unpack,
force_encoding, concatenation in both directions -- US-ASCII and ASCII-only
UTF-8 agree on every one. What differs:
1.to_s.encoding # CRuby: US-ASCII Spinel: UTF-8
(1.to_s + "x").encoding
# CRuby: US-ASCII Spinel: UTF-8
1.chr.inspect # CRuby: "\x01" Spinel: "\u0001"The encoding's NAME, and inspect's escape form for a non-printable byte.
CRuby needs the name because encodings are first-class objects and every string
must report one; a program cannot name an encoding in spinel, so there is
nothing for the third name to distinguish. The 7-bit fact itself is not lost --
spinel keeps it as a bit in the string header, where it makes indexing O(1)
rather than naming anything.
CRuby raises Encoding::CompatibilityError when a two-string operation
(include?, index, +, sub, start_with?, ...) is handed operands whose
encodings are incompatible and whose bytes are not all ASCII:
"café".include?("é".b) # CRuby: Encoding::CompatibilityError
# Spinel: trueThe error guards against a byte match that is not a character match, which is a real hazard when the two operands are, say, Shift_JIS and UTF-8: the same bytes mean different characters. Spinel has two encodings, UTF-8 and ASCII-8BIT, and they share one byte representation -- ASCII-8BIT is bytes with no character interpretation at all, so there is no second interpretation for the first one to disagree with. The failure the error exists to prevent cannot happen here, so Spinel answers the byte question instead of refusing it.
Where CRuby produces a value rather than an error, Spinel matches it.
String#==, #eql?, #<=> and #hash follow CRuby's rb_str_comparable:
equal bytes are equal strings only when the encodings are comparable -- the
same encoding, or both operands ASCII only. That matters beyond the comparison
itself, because it decides whether a Hash keeps a binary blob and a text
string as one key or two.
The visible consequence of drawing the line there is an asymmetry:
"café".include?("café".b) # true -- a byte search
"café" == "café".b # false -- CRuby's answer, and the Hash-key ruleCRuby has the same pair; it just answers the first with an exception rather
than with true.
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) |
alias / alias_method inside a reopened String / Integer / Float / Symbol (class String; alias starts_with? start_with?), naming a builtin method or one the reopen defined |
works on a concretely typed or implicit-self receiver; a poly (run-time-typed) receiver does not see the alias yet |
A program's own instance methods on Range, Time, File and Class (class Range; def blank? = false, def span = last - first), and class methods on File |
works on a concretely typed receiver, with self the builtin value and a receiverless builtin call (last) resolving on it; a poly (run-time-typed) receiver reaches a Range / Time method too. Used to be a C typedef collision (sp_Range) before any call |
activesupport's blank.rb shape: reopens of Object, NilClass, TrueClass / FalseClass, Array, Hash, Symbol, String, Numeric and Time each defining blank? / present?, present? calling the reopen's own blank? bare, alias_method :blank?, :empty? on Array / Hash / Symbol, and Object#blank? asking respond_to?(:empty?) of a self that may be anything |
works: every receiver kind, concretely typed or poly, reaches its own class's definition (nil is blank, [] is blank, a user object answers through Object's), and Integer / Float fall to Numeric's. Inside an Object / Numeric reopen a bare call goes through self, so present? inside Object#presence reaches String#present? for a String as Ruby's lookup does. An Array / Hash reopen method is reached on a concretely typed receiver and through a poly receiver holding a container of any element kind |
| Hash variant inference (a wrong initial guess widens to poly transparently) | correct (a perf cost, not a correctness limit) |
A synthesized attr_writer inherited by sibling user classes and called through a boxed receiver |
works when the candidate classes share one effective attribute family; inherited writer copies use the common ancestor's slot. An explicit writer override and an unrelated same-named attribute stay separate; Spinel does not infer their ivar types from the writer name alone. |
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.
String line iteration with a nil separator yields the receiver itself. A block
that mutates that String while another name observes it is refused because the
line iterator cannot bind the receiver handle. Array#fill block results use
shared handles under --share-strings; the default build refuses an observed
mutation through a retained String result.