The Ruby that compiles
The Ruby that compiles: what the whole-program compiler forbids, and the workaround for each.
melee apps are compiled to a native binary by Spinel, a whole-program Ruby compiler. The language is real Ruby, but anything that decides what to call at runtime cannot be compiled ahead of time, and a few of the standard library’s parts are missing. This page is the list. Every rule has the reason in one clause and the thing to write instead.
The same code also runs under CRuby for melee dev, so a habit that works here works in both.
No runtime metaprogramming
eval, instance_eval/class_eval with a String, method_missing, define_method with a computed name,
send with a name that is not a literal somewhere in the program, Class.new, runtime include, extend,
attr_accessor outside a class body, ObjectSpace, .methods, .instance_variables, refinements — none of
these compile, because the compiler must see every call.
Write it out. Where you would dispatch on a name, use a case:
def label_for(kind)
case kind
when "note" then "Note"
when "event" then "Event"
else "Item"
end
end
No threads
Thread, Mutex, Queue, thread pools, background workers. One request is one process and it exits when
the response is written; nothing outlives it. Do the work in the request, or write a row and do it on a
later request (an admin “refresh now” button, as the kitchen app does), or give it to a durable object with
a timer.
melee check refuses them by name, so you find out at build time. It has to: the compiler accepts threads
and they behave under Spinel much as they do under CRuby, but a request is served by a fork of the warm
process and a fork keeps only the calling thread — a thread you started when the app loaded is not there in
the child that serves the request, and a lock held at fork time is held for ever. That failure only appears
in production, which is why the check exists rather than a note here.
A rescue names the class in full
Spell the exception class the whole way down from the top:
rescue Melee::HTTP::Error => e # matches
rescue HTTP::Error => e # never matches
HTTP is an alias for Melee::HTTP, and a rescue that reaches its class through a constant alias does not
match under Spinel: the exception walks straight past the handler and out of the request. An alias bound
straight to the class — your own E = Melee::HTTP::Error, and rescue E — never matches. An alias to the
namespace — rescue HTTP::Error — matches only while no other class anywhere in the program has the same
last name segment, and Error stops being unique the moment anything pulls in uri, which net/http does
for you. So in an app, neither spelling works. CRuby matches all of them, which is why the app looks right
under melee dev and only drops the error once it is deployed; melee shipped that bug in its own example app
before the check existed.
melee check refuses both, naming the file, the line and the path to write instead. A class of your own is
unaffected — rescue Feeds::NotFound matches on both runtimes — and so are calls through the alias:
HTTP.get and Melee::HTTP.get are the same call.
No Date, no Time.parse
There is no Date, no DateTime, and require "time" does not add Time.parse — the parsing half of Time
is not there. What you have is Time.now, Time.at(seconds), Time.local(y, m, d, h, min, s),
Time.utc(...), #strftime, #to_i, arithmetic in seconds, and the accessors (year, month, hour, …).
DAY = 86_400
def parse_date(s) # "2026-09-05" -> Time, or nil
return nil unless s.match?(/\A\d{4}-\d\d-\d\d\z/)
Time.local(s[0, 4].to_i, s[5, 2].to_i, s[8, 2].to_i)
end
Store timestamps as INTEGER Unix seconds (Time.now.to_i) or as TEXT in YYYY-MM-DD, and format with
strftime. Slice and to_i to parse.
No gems, and a short require list
There is no bundler and no Gemfile. require resolves only these: json, base64, digest,
securerandom, uri, net/http, openssl, set, csv, strscan, optparse, pathname, tmpdir,
forwardable. Anything else is a build error. require_relative "lib/ical" for your own files is fine and
is how lib/ is used.
Two exceptions inside that list: stringio exists but must never be required — a StringIO anywhere in the
program breaks method dispatch on real IO objects — and erb is a stub, which is why templates are compiled
at build time instead.
The melee stdlib already requires json, net/http, openssl, uri, securerandom and base64, so they
are available in app.rb without a require.
Frozen string literals
Every String literal is frozen, always. Put # frozen_string_literal: true at the top of your files so
CRuby agrees. To build a String, start from +"" or interpolation and append:
out = +""
rows.each { |r| out << r["text"].to_s << "\n" }
out = "" then out << x raises FrozenError.
Types are inferred, so keep containers uniform
The compiler gives every expression one type. An array holds one kind of thing; an empty [] that never
receives anything is an array of Integers. Two consequences:
- Do not put mixed kinds in one array. For a pair, use a two-key Hash or a small class with
attr_reader. - Prefer
map/selectoveracc = []followed by pushes of different shapes.
Hash keys must be Strings, Symbols, Integers or Floats — an Array cannot be a key, and your own #hash and
#eql? are ignored. Range bounds must be Integer, Float or String.
Values that may be nil need narrowing before you use them: row["n"].to_i, params.fetch(:x),
ENV["K"].to_s. Reading a missing key out of a numeric container gives a sentinel rather than raising, so
comparisons can quietly succeed on nonsense — check key? or use fetch with a default.
Data with behaviour: no blocks in containers
A Proc reached through a Hash or an Array cannot be called — undefined method 'call' for an instance of Proc at runtime — so a registry of little functions does not work. This is the shape most often reached for
first, and it is the one to unlearn:
# Does not work: the Proc comes back out of the Hash and cannot be called.
APPS = { "bluesky" => ->(who) { "https://bsky.app/profile/#{who}" } }
APPS["bluesky"].call("ada")
Store data and put the behaviour in a method. A placeholder String plus sub covers most of it:
APPS = {
"bluesky" => "https://bsky.app/profile/{who}",
"github" => "https://github.com/{who}"
}.freeze
def profile_url(app, who)
template = APPS[app]
template.nil? ? nil : template.sub("{who}", url_encode(who))
end
Where the behaviour really differs per entry, use a case keyed on the same String. Blocks are fine
everywhere else — each, map, db.transaction do, a route block — as long as they are written at the
call site rather than stored and fetched.
Errors that only appear at runtime
Wrong arity, calling a method on nil, and Integer + String all compile and raise when they run. So does
any undefined method with an explicit receiver, whatever the compiler knows about that receiver’s type:
"x".nope, 1.nope, db.frist(sql) and Household.get("home").refrsh all build cleanly and raise
NoMethodError on the line. What the compiler does reject is a receiverless call it cannot resolve — a
top-level helper you never defined, or self.nope. (Measured 2026-09-13 against the pinned Spinel
a9f1d9ae; melee check catching a misspelt method is not something to rely on.)
So the build succeeding is not proof the app works — run it under melee dev and click through it.
And there are no backtraces: Exception#backtrace and caller return [] in a compiled app. Raise with
messages that say where you were (raise ArgumentError, "feed #{id} has no url"), and log before the risky
line, not only after it.
Smaller edges
- A method that takes a block and calls itself recursively does not compile. Split it: a public method that takes the block, a private one that recurses.
defined?is resolved at compile time; do not use it to detect a runtime environment.- Integer overflow raises rather than promoting to bignum.
- Strings are UTF-8 or binary; there are no other encodings, and
String#bdoes not exist. - The regexp engine is its own: at most 31 capture groups, and backtracking is bounded, so a pathological pattern fails rather than hanging.
- Treat the filesystem as read-only. The app’s directory cannot be written to; the database is the place for
state. Do not shell out with
systemor backticks — nothing is guaranteed to be installed.