Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/select over acc = [] 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#b does 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 system or backticks — nothing is guaranteed to be installed.