Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
[![License](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE)
[![Go](https://img.shields.io/github/go-mod/go-version/go-embedded-ruby/ruby?label=go&color=00ADD8)](https://go.dev/dl/)
[![Release](https://img.shields.io/github/v/release/go-embedded-ruby/ruby?label=release&color=blue)](https://github.com/go-embedded-ruby/ruby/releases/latest)
[![ruby/spec](https://img.shields.io/badge/ruby%2Fspec-23%2C511%20examples%20passing-1a7f37)](#runtime-conformance--rubyspec)
[![ruby/spec](https://img.shields.io/badge/ruby%2Fspec-23%2C673%20examples%20passing-1a7f37)](#runtime-conformance--rubyspec)

**A Ruby interpreter written in pure Go, with cgo disabled** — so you can embed it
in a Go program with `import "github.com/go-embedded-ruby/ruby"`, or ship it as a
single static binary that cross-compiles wherever Go does, with no C toolchain and
no libruby.

**How complete is it?** It runs **23,511** of ruby/spec's `language/` + `core/`
**How complete is it?** It runs **23,673** of ruby/spec's `language/` + `core/`
examples ([what that counts](#runtime-conformance--rubyspec)) — a large and growing
subset of the language, and **not** a drop-in replacement for CRuby. Read
[What does not work yet](#what-does-not-work-yet) before you depend on it; the
Expand Down Expand Up @@ -153,6 +153,14 @@ writing. Only the baseline is machine-checked against this file
from the sweep named below and are not derivable from the baseline, which
records passing counts and nothing else.

⛔ **The table is a sweep taken at `RUBY_VERSION` 3.4.1**, before the move to
4.0.9. Its rows are left as they were measured rather than rewritten, because
they come from one dated run and are not derivable from the baseline; the badge
and the summary above count the current `BASELINE` and so already reflect 4.0.9.
The move was measured separately — both engines built from one commit minus the
version string — at **+144 passing and 27 examples that went red**
([#799](https://github.com/go-embedded-ruby/ruby/issues/799)).

Measured 2026-09-28 on `862a9f3`, darwin/arm64, against the corpus pinned at
`SPEC_SHA=87b1631992bd00cf0c4934474766d54dad088191`, on an isolated snapshot of
it so no concurrent run could swap the shim underneath, and reached through a
Expand Down Expand Up @@ -763,7 +771,7 @@ Otherwise:

| | how it differs from MRI 4.0.5 |
| --- | --- |
| `RUBY_VERSION` | rbgo reports `"3.4.1"`; the differential oracle it is developed against is MRI 4.0.5, and the latest MRI release is 4.0.9 (2026-10-23). The Ruby 4.0 **`Ruby` namespace** *is* implemented — all nine of `Ruby::{VERSION,RELEASE_DATE,PLATFORM,PATCHLEVEL,REVISION,COPYRIGHT,ENGINE,ENGINE_VERSION,DESCRIPTION}` are the **same objects** as their `RUBY_`-prefixed globals, as `version.c` defines them, so `Ruby::VERSION.equal?(RUBY_VERSION)` is `true`. The conformance shim resolves `ruby_version_is` at the reported `3.4.1`, so the ten examples asserting that in `core/builtin_constants` are **not counted by the gate**: measured here, that file yields 17 at 3.4.1 and 27 at 4.0.9 ([#799](https://github.com/go-embedded-ruby/ruby/issues/799)) |
| `RUBY_VERSION` | rbgo reports `"4.0.9"`, the latest MRI release (2026-10-23). The Ruby 4.0 **`Ruby` namespace** *is* implemented — all nine of `Ruby::{VERSION,RELEASE_DATE,PLATFORM,PATCHLEVEL,REVISION,COPYRIGHT,ENGINE,ENGINE_VERSION,DESCRIPTION}` are the **same objects** as their `RUBY_`-prefixed globals, as `version.c` defines them, so `Ruby::VERSION.equal?(RUBY_VERSION)` is `true`. The move from `3.4.1` was measured before it was made, with both engines built from one commit minus the version string: **+144 passing** over `core` and `language`, and **27 examples that went red**, which no pass total shows — see [#799](https://github.com/go-embedded-ruby/ruby/issues/799) for the list of what each of the 27 needs |
| `Numeric#to_int` | not defined — `Complex(2.9, 0).to_int` raises `NoMethodError`; MRI returns `2` |
| `File::Stat#==` | `a == b` is `false` for two stats of the same file (MRI: `true`); `a.==(b)` answers `true` |
| `File#stat` on a file unlinked while open | raises `Errno::ENOENT`; MRI answers from the open descriptor. rbgo's streams are buffered by path and hold no descriptor |
Expand Down Expand Up @@ -1248,7 +1256,7 @@ specification of the language — through `rbgo` under a minimal MSpec-compatibl
shim, and gates CI on a **per-file** baseline
([`scripts/conformance/rubyspec/`](scripts/conformance/rubyspec/), in
`BASELINE`). No file may pass fewer examples than its record or stop loading, so
measured language conformance moves in one direction; the total, **23,511**, is
measured language conformance moves in one direction; the total, **23,673**, is
a derived summary rather than the thing gated. Run it with
`scripts/conformance/rubyspec/run.sh`, and see *Runtime conformance* under
*Status* for the full breakdown.
Expand Down
30 changes: 20 additions & 10 deletions internal/compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2202,12 +2202,22 @@ func blockParamsAreImplicit(blk *ast.Block) bool {
// name AFTER the body is compiled: resolution during the body used the real
// name, and the ISeq that remains carries none.
//
// The numbered parameters `_1`..`_9` are deliberately NOT hidden. They are named
// in #parameters ([[:opt, :_1]]) and, for the Ruby version rbgo reports
// (RUBY_VERSION 3.4.1), they are visible to a Binding too — ruby/spec pins that
// under `ruby_version_is ""..."4.0"` and pins the hiding only from 4.0. Hiding
// them here would be answering a question rbgo has not yet said it is answering;
// it belongs with the RUBY_VERSION bump.
// ⛔ THE NUMBERED PARAMETERS ARE HIDDEN FROM b.locals AND NOT FROM b.params, and
// that asymmetry is the whole of Ruby 4.0's change here. `_1` leaves
// Binding#local_variables but STAYS in Proc#parameters:
//
// proc { _1 }.parameters # => [[:opt, :_1]] -- ungated, every version
// -> { _1; binding.local_variables }.call("a")
// # => [:_1] below 4.0, [] from 4.0
//
// numbered_parameters_spec.rb:85 ("affects block parameters") carries no version
// guard at all, so blanking b.params as well would trade four examples for two.
// `it` is blanked from BOTH, because MRI gives it no name in #parameters either
// (`-> { it }.parameters` is [[:req]], NAMELESS).
//
// This was held back until the RUBY_VERSION bump, correctly: below 4.0
// ruby/spec asserts the OPPOSITE, under `ruby_version_is ""..."4.0"`. The two
// land together.
//
// ⛔ BOTH KINDS ARE RECORDED, hidden or not. Ruby 4.0 adds
// Binding#implicit_parameters, #implicit_parameter_get and
Expand All @@ -2229,13 +2239,13 @@ func hideImplicitBlockParams(b *builder, blk *ast.Block, positionals []string) {
// gate named it, which is the right way to find out.
for i, name := range positionals {
b.markImplicitParam(i, name)
if name != "it" {
continue
}
// Out of the local table: both kinds, from 4.0.
if i < len(b.locals) {
b.locals[i] = ""
}
if i < len(b.params) {
// Out of #parameters: `it` only. A numbered parameter keeps its name
// there, which numbered_parameters_spec.rb:85 asserts with no guard.
if name == "it" && i < len(b.params) {
b.params[i] = ""
}
}
Expand Down
13 changes: 9 additions & 4 deletions internal/compiler/wave29_kwargs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,18 @@ func TestImplicitBlockParamsAreHidden(t *testing.T) {
if len(writtenIt.Params) != 1 || writtenIt.Params[0] != "it" {
t.Errorf("f { |it| it }: Params = %q, want [\"it\"]", writtenIt.Params)
}
// The numbered parameters keep theirs, in both tables.
// ⛔ THE NUMBERED PARAMETERS SPLIT THE TWO TABLES, and this asymmetry is the
// whole of Ruby 4.0's change. They keep their names in Params, because
// `proc { _1 }.parameters` is [[:opt, :_1]] under no version guard at all
// (numbered_parameters_spec.rb:85), and lose them in Locals, because from
// 4.0 they are not local variables (:112-135). `it` loses both; a numbered
// parameter loses one.
np := blockOf(t, "f { _1 + _2 }")
if len(np.Params) != 2 || np.Params[0] != "_1" || np.Params[1] != "_2" {
t.Errorf("f { _1 + _2 }: Params = %q, want [\"_1\" \"_2\"]", np.Params)
t.Errorf("f { _1 + _2 }: Params = %q, want [\"_1\" \"_2\"] -- #parameters is ungated", np.Params)
}
if len(np.Locals) < 2 || np.Locals[0] != "_1" || np.Locals[1] != "_2" {
t.Errorf("f { _1 + _2 }: Locals = %q, want the numbered names kept", np.Locals)
if len(np.Locals) < 2 || np.Locals[0] != "" || np.Locals[1] != "" {
t.Errorf("f { _1 + _2 }: Locals = %q, want the numbered names blanked from 4.0", np.Locals)
}
}

Expand Down
45 changes: 39 additions & 6 deletions internal/vm/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ func (vm *VM) registerBinding() {
cBinding.define("local_variable_get", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value {
b := self.(*Binding)
name := vm.bindingVarName(args[0])
refuseNumberedParam(name)
i := b.slotOf(name)
if i < 0 {
raise("NameError", "local variable '%s' is not defined for %s", name, b.ToS())
Expand All @@ -302,6 +303,7 @@ func (vm *VM) registerBinding() {
cBinding.define("local_variable_set", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value {
b := self.(*Binding)
name := vm.bindingVarName(args[0])
refuseNumberedParam(name)
if i := b.slotOf(name); i >= 0 {
// The write lands where the name LIVES, which for a binding taken inside a
// block is an ANCESTOR env. Writing b.env.slots[i] instead put the value in
Expand All @@ -318,7 +320,12 @@ func (vm *VM) registerBinding() {
return args[1]
})
cBinding.define("local_variable_defined?", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value {
return object.Bool(self.(*Binding).slotOf(vm.bindingVarName(args[0])) >= 0)
name := vm.bindingVarName(args[0])
// ⛔ RAISES, it does not answer false. MRI puts the refusal before the
// lookup (proc.c v4.0.9:649), so this is not "no such local" — it is
// "that is not the kind of question this method answers".
refuseNumberedParam(name)
return object.Bool(self.(*Binding).slotOf(name) >= 0)
})

// Ruby 4.0's implicit-parameter trio (proc.c v4.0.9:680-783). They are the
Expand Down Expand Up @@ -388,15 +395,41 @@ func copyImplicit(m map[string]int) map[string]int {
return out
}

// isImplicitParamName is MRI's rb_implicit_param_p: `it`, or `_` followed by a
// single digit 1..9. `_0` and `_10` are ordinary locals.
// isImplicitParamName is MRI's rb_implicit_param_p (proc.c v4.0.9:517-521):
// `it`, or `_` followed by a single digit 1..9. `_0` and `_10` are ordinary
// locals.
func isImplicitParamName(name string) bool {
if name == "it" {
return true
}
return name == "it" || isNumberedParamName(name)
}

// isNumberedParamName is MRI's rb_numparam_id_p (proc.c v4.0.9:511-515), which
// tests `_1`..`_9` and NOT `it`.
//
// ⛔ THE TWO PREDICATES ARE NOT INTERCHANGEABLE, and MRI uses this narrower one
// in all three of Binding's local-variable methods (proc.c:549, 602, 649) while
// rb_implicit_param_p is used elsewhere. The reason is not a detail: `it` is a
// LEGAL local variable name in Ruby — `it = 5` compiles — so
// `binding.local_variable_get(:it)` must answer normally, while `_1` can never
// be a local and is refused. Widening the refusal to rb_implicit_param_p would
// break every binding that happens to hold a variable called `it`.
func isNumberedParamName(name string) bool {
return len(name) == 2 && name[0] == '_' && name[1] >= '1' && name[1] <= '9'
}

// refuseNumberedParam is the guard MRI puts at the top of #local_variable_get,
// #local_variable_set and #local_variable_defined? (proc.c v4.0.9:549, 602,
// 649), from Ruby 4.0.
//
// ⛔ IT TESTS THE NAME, NOT THE BINDING, and fires BEFORE any lookup — so
// `binding.local_variable_get(:_1)` raises this even in a binding that has no
// numbered parameter at all, and even at the top level. `_1` is not a local
// variable that happens to be hidden; it is not a local variable name.
func refuseNumberedParam(name string) {
if isNumberedParamName(name) {
raise("NameError", "numbered parameter '%s' is not a local variable", name)
}
}

// implicitParameterNames is bind_implicit_parameters (proc.c v4.0.9:680): [:it]
// when `it` is this scope's implicit parameter, otherwise the numbered ones in
// order. A block has one kind or the other and never both — `proc { |x| _1 }` is
Expand Down
13 changes: 11 additions & 2 deletions internal/vm/binding_implicit_params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,18 @@ func TestImplicitParametersAreNotLocalVariables(t *testing.T) {
`rescue NameError => e; r = e.message; end }; r`,
want: `local variable 'it' is not defined for #<Binding>`,
}, {
name: "while _1 stays visible at the version rbgo reports",
name: "and _1 is hidden the same way from 4.0",
src: `r = nil; [1].each { _1; r = binding.local_variables.include?(:_1) }; r.inspect`,
want: `true`,
want: `false`,
}, {
// ⛔ But NOT the same way through local_variable_get. `it` is a legal
// local variable name, so asking for it gets the ordinary "not defined"
// above; `_1` can never be one, and MRI refuses it by name before any
// lookup (proc.c v4.0.9:549). Two different messages, deliberately.
name: "though with a different refusal, because _1 is not a NAME a local can have",
src: `r = nil; [1].each { _1; begin; binding.local_variable_get(:_1); ` +
`rescue NameError => e; r = e.message; end }; r`,
want: `numbered parameter '_1' is not a local variable`,
}} {
t.Run(tc.name, func(t *testing.T) {
if got := evalString(t, tc.src); got != tc.want {
Expand Down
165 changes: 165 additions & 0 deletions internal/vm/binding_numbered_params_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package vm

import "testing"

// Ruby 4.0 takes the numbered parameters out of Binding's local-variable API
// (language/numbered_parameters_spec.rb:112-135, opened by `ruby_version_is
// "4.0"`; the block at :93-110 asserts the opposite below it and closes).
//
// ⛔ THE REFUSAL IS ABOUT THE NAME, NOT THE BINDING. MRI tests
// rb_numparam_id_p(lid) at the TOP of each of the three methods, before any
// lookup happens (proc.c v4.0.9:549, 602, 649), so `_1` raises in a binding
// that has no numbered parameter at all. `_1` is not a local variable that is
// being hidden; it is not a local variable name.
func TestNumberedParametersAreNotLocalVariables(t *testing.T) {
const msg = "numbered parameter '_1' is not a local variable"
for _, tc := range []struct{ name, src, want string }{
{
"local_variable_get raises",
`begin; -> { _1; binding.local_variable_get(:_1) }.call("a"); rescue NameError => e; e.message; end`,
msg,
},
{
"local_variable_set raises",
`begin; -> { _1; binding.local_variable_set(:_1, "b") }.call("a"); rescue NameError => e; e.message; end`,
msg,
},
{
// RAISES rather than answering false, which is the easy one to get
// wrong: "not defined" is the natural reading and the wrong one.
"local_variable_defined? raises rather than answering false",
`begin; -> { _1; binding.local_variable_defined?(:_1) }.call("a"); rescue NameError => e; e.message; end`,
msg,
},

// ⛔ Before the lookup: no numbered parameter is in scope anywhere here,
// and it still raises. A guard placed after the lookup would answer
// "local variable '_1' is not defined for ..." instead.
{
"it raises even where no numbered parameter exists",
`begin; binding.local_variable_get(:_1); rescue NameError => e; e.message; end`,
msg,
},
{
"and for defined?, where false would otherwise be the answer",
`begin; binding.local_variable_defined?(:_9); rescue NameError => e; e.message; end`,
"numbered parameter '_9' is not a local variable",
},

// ⛔ CONTROL: `it` is a LEGAL local variable name, so the refusal must
// NOT widen to rb_implicit_param_p. MRI uses the narrower
// rb_numparam_id_p in all three methods for exactly this reason.
{
"it is an ordinary local variable name and is not refused",
`it = 5; binding.local_variable_get(:it).inspect`,
"5",
},
{
"and can be set through a binding",
`it = 5; binding.local_variable_set(:it, 6); it.inspect`,
"6",
},
{
"and answers defined?",
`it = 5; binding.local_variable_defined?(:it).inspect`,
"true",
},

// ⛔ CONTROL: the boundary of the name shape. `_0` and `_10` are
// ordinary locals in MRI, so a refusal that tested "starts with _" or
// "_ then digits" would reject names the program may legitimately use.
{
"_0 is an ordinary local",
`_0 = 1; binding.local_variable_defined?(:_0).inspect`,
"true",
},
{
"_10 is an ordinary local",
`_10 = 1; binding.local_variable_defined?(:_10).inspect`,
"true",
},
{
"a plain underscore is an ordinary local",
`_ = 1; binding.local_variable_defined?(:_).inspect`,
"true",
},

// ⛔ CONTROL, and the trade this change must NOT make: #parameters is
// ungated (numbered_parameters_spec.rb:85) and still names `_1`. Only
// the LOCAL TABLE loses the name, never the parameter list -- which is
// why the compiler blanks b.locals and leaves b.params alone.
{
"a proc still names its numbered parameters",
`proc { _1 }.parameters.inspect`,
"[[:opt, :_1]]",
},
{
"and a lambda does too",
`-> { _2 }.parameters.inspect`,
"[[:req, :_1], [:req, :_2]]",
},

// ⛔ CONTROL: they did not vanish, they moved. 4.0 gives them an API of
// their own, and taking them out of one must not take them out of both.
{
"implicit_parameters still sees them",
`-> { _1; binding.implicit_parameters }.call("a").inspect`,
"[:_1]",
},
{
"and implicit_parameter_get still reads them",
`-> { _1; binding.implicit_parameter_get(:_1) }.call("a").inspect`,
`"a"`,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := evalString(t, tc.src); got != tc.want {
t.Errorf("%s\n got %q\nwant %q", tc.src, got, tc.want)
}
})
}
}

// #local_variables is tested through runSrc rather than evalString because
// evalString wraps the source in `__v = begin … end`, and that assignment makes
// `__v` a local of the ENCLOSING scope — which a binding taken inside a block
// lists as well. The first run of the cases above read `[:__v]` where it wanted
// `[]`: the numbered parameter was already gone and the harness was supplying
// the remaining entry. An assertion written against that output would have
// pinned my wrapper's name as Ruby behaviour.
func TestNumberedParametersAreNotInLocalVariables(t *testing.T) {
for _, tc := range []struct{ name, src, want string }{
// The two examples numbered_parameters_spec.rb:113-116 opens at 4.0.
{
"one numbered parameter",
`p(-> { _1; binding.local_variables }.call("a"))`,
"[]",
},
{
"several",
`p(-> { _2; binding.local_variables }.call("a", "b"))`,
"[]",
},
// ⛔ CONTROL: the list is not simply empty for every block. A real local
// still appears, so the two cases above measure the hiding and not a
// #local_variables that stopped working.
{
"a real local in the same shape still appears",
`p(-> { x = 1; binding.local_variables }.call)`,
"[:x]",
},
// ⛔ CONTROL: `it` is blanked from the local table the same way, and
// this is the 4.0 behaviour rbgo already had before the version moved.
{
"it is not listed either",
`p(-> { it; binding.local_variables }.call("a"))`,
"[]",
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := runSrc(t, tc.src); got != tc.want {
t.Errorf("%s\n got %q\nwant %q", tc.src, got, tc.want)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/vm/kernel_introspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
// function-local, which is how a CLI ends up printing a version the running VM
// does not report.
const (
rubyVersion = "3.4.1"
rubyVersion = "4.0.9"
releaseDate = "2024-12-25"
revision = "48d4efcb85000e1ebae42004e963b5d0cedddcf2"
)
Expand Down
Loading
Loading