diff --git a/go.mod b/go.mod index cfca96e2..6353d1f9 100644 --- a/go.mod +++ b/go.mod @@ -108,6 +108,7 @@ require ( github.com/go-ruby-pundit/pundit v0.0.0-20260706120141-d3cc8a101bc3 github.com/go-ruby-racc/racc v0.0.0-20260630123809-0d492278523f github.com/go-ruby-rack/rack v0.0.0-20260705200150-888027c33329 + github.com/go-ruby-rails/rails v0.0.0-20260706183557-5ddb406695b0 github.com/go-ruby-railties/railties v0.0.0-20260706120201-300b4fe0f2df github.com/go-ruby-rdoc/rdoc v0.0.0-20260702162339-c866323cc54e github.com/go-ruby-redis/redis v0.0.0-20260701125752-5de216f6ad92 diff --git a/go.sum b/go.sum index fdcd8f98..caa96801 100644 --- a/go.sum +++ b/go.sum @@ -378,6 +378,8 @@ github.com/go-ruby-racc/racc v0.0.0-20260630123809-0d492278523f h1:+PEruR5OxctQr github.com/go-ruby-racc/racc v0.0.0-20260630123809-0d492278523f/go.mod h1:HeFcYYwifNZvQzF9jdaSFViHYT0mUNLwWEFPQj0j6V4= github.com/go-ruby-rack/rack v0.0.0-20260705200150-888027c33329 h1:4sXRBW9MXaEuosiyzzk1Fh+C8R2+PiPdua5sutdvV+E= github.com/go-ruby-rack/rack v0.0.0-20260705200150-888027c33329/go.mod h1:rKZ3vD81gJq61NvZ3XX9cr6AxtIV3+lFZKG7lPKV3PM= +github.com/go-ruby-rails/rails v0.0.0-20260706183557-5ddb406695b0 h1:igwKtg0QdPTtWX6U7MTy8KKRbrAkqfoR6JHb2uPh/qs= +github.com/go-ruby-rails/rails v0.0.0-20260706183557-5ddb406695b0/go.mod h1:0RYYAMUG0AGHYVzGF0V+0CBLsPTEFUgXe60YW9ik1+8= github.com/go-ruby-railties/railties v0.0.0-20260706120201-300b4fe0f2df h1:GQCDL/SGGtHqmDB2IfgCFe7AsgAA2LuhHg0kbbpYoFA= github.com/go-ruby-railties/railties v0.0.0-20260706120201-300b4fe0f2df/go.mod h1:AKDyiSCjqbfeZaNm8IsIUXao8ATsl2LQ9kdT5GaqYx4= github.com/go-ruby-rdoc/rdoc v0.0.0-20260702162339-c866323cc54e h1:Orev3pHC09GghkceOci3+dlV4Untoly525O7MyFdXOY= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6721fcd8..ce8ce6f1 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -496,6 +496,7 @@ func (vm *VM) bootstrap() { vm.registerActiveStorage() // ActiveStorage::Blob/Service/Attachment + Attached::One/Many (require "active_storage"), backed by go-ruby-activestorage; ModelStore/Service/Signer/Random/Clock seams wired to a deterministic in-process config (MemStore + DiskService temp dir); needs StandardError for ActiveStorage::Error vm.registerActionCable() // ActionCable::Server/Connection::Base/Channel::Base + ActionCable.server (require "action_cable"), backed by go-ruby-actioncable; the transport/channel-action/factory seams eval Ruby bodies + the async adapter default keep the byte-exact actioncable-v1-json wire protocol in the library; needs StandardError (ActionCable::Error) vm.registerRailties() // Rails::Railtie/Engine/Application boot framework (require "rails"), backed by go-ruby-railties; the initializer/hook/routes blocks are the rbgo seams (run inline on initialize!); a later rails meta-gem layers the top-level Rails.* accessors + vm.registerRails() // the `rails` meta-gem (require "rails/all"), backed by go-ruby-rails: extends railties' Rails module with the top-level singleton accessors (Rails.application/env/root/…), Rails::VERSION, Rails::EnvironmentInquirer and the component catalog; MUST run after registerRailties (extends the Rails module it created) vm.registerDevise() // Devise::Config/Resource/Encryptor/TokenGenerator + Strategies::DatabaseAuthenticatable (require "devise"), backed by go-ruby-devise over go-ruby-bcrypt + go-ruby-warden; the model #[]/#[]=/#save + finder callable are the rbgo seams, and the strategy plugs into the bound Warden registry; needs StandardError (Devise::Error). Registered after registerWarden + registerBCrypt (dependency order) vm.registerHanami() // Hanami::Router (hanami-router) + Hanami::Action (hanami-controller) (require "hanami" / "hanami/router" / "hanami/action"), backed by go-ruby-hanami over go-ruby-rack; the endpoint Resolver, the action #handle body (ActionCall), before/after callbacks, handle_exception, params validation and session loading are the rbgo seams; needs Rack, so registered after registerRack vm.registerActiveRecord() // ActiveRecord::Model/Relation/Record + Base.establish_connection (require "active_record"), backed by go-ruby-activerecord; adapter seam wired to go-ruby-sqlite3 so queries run; needs StandardError for ActiveRecordError diff --git a/internal/vm/digest_bind.go b/internal/vm/digest_bind.go index 0c41ea19..3cb29c38 100644 --- a/internal/vm/digest_bind.go +++ b/internal/vm/digest_bind.go @@ -275,6 +275,11 @@ func hasCustomEq(_ *VM, v object.Value) bool { // Rails::StringInquirer#== compares the wrapped string by value (env == // "production"), not object identity, so it must dispatch its own ==. return true + case *RailsEnvVal: + // Rails::EnvironmentInquirer#== compares the wrapped environment name by + // value (Rails.env == "production"), not object identity, so it must + // dispatch its own ==. + return true } return false } diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 297665ed..840dc6e7 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1058,6 +1058,8 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *StringInquirerVal: return x.cls + case *RailsEnvVal: + return x.cls case *DeviseConfig: return x.cls case *DeviseResource: diff --git a/internal/vm/rails_meta.go b/internal/vm/rails_meta.go new file mode 100644 index 00000000..3c8c41d9 --- /dev/null +++ b/internal/vm/rails_meta.go @@ -0,0 +1,237 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + rails "github.com/go-ruby-rails/rails" + railties "github.com/go-ruby-railties/railties" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerRails layers the `rails` meta-gem onto the boot framework the railties +// binding already installed (registerRailties, which must run first). In Ruby the +// `rails` gem ships almost no code of its own: it is the meta-gem that ties the +// framework's components together and vends the top-level Rails module singleton +// accessors (Rails.application / env / root / logger / cache / configuration / +// autoloaders / groups / public_path / backtrace_cleaner / error), the +// Rails::VERSION constant, and Rails::EnvironmentInquirer (the Rails.env type). +// +// This binding does NOT re-create the Rails module or its Railtie/Engine/ +// Application/StringInquirer classes — railties already registered those and the +// "rails" provided feature. It extends the existing Rails module in place with +// the singleton accessors, adds Rails::VERSION and Rails::EnvironmentInquirer, and +// wires `require "rails/all"` to pull in every shipped framework component. +// +// The application-state accessors (root / public_path / configuration / +// autoloaders / cache) delegate to the meta-gem, which resolves them against the +// railties Application registered through rails.SetApplication — the App seam +// bridged here by railsAppAdapter. Rails.application itself keeps returning the +// Ruby Application wrapper (a *RailsAppVal) that Ruby set. +func (vm *VM) registerRails() { + // registerRailties (which runs first) already created the Rails module and its + // Railtie/Engine/Application/StringInquirer classes; this binding extends that + // module in place rather than recreating it. + mod := vm.consts["Rails"].(*RClass) + + def := func(name string, fn func(*VM, object.Value, []object.Value, *Proc) object.Value) { + mod.smethods[name] = &Method{name: name, owner: mod, native: fn} + } + + // Rails.application / Rails.application= — the getter returns the Ruby + // Application wrapper Ruby registered; the setter records it and threads the + // underlying railties Application into the meta-gem's App seam so the + // delegating accessors resolve against it. Assigning nil clears the app. + def("application", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.railsApp == nil { + return object.NilV + } + return vm.railsApp + }) + def("application=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + if object.IsNil(args[0]) { + vm.railsApp = nil + rails.SetApplication(nil) + return object.NilV + } + av, ok := args[0].(*RailsAppVal) + if !ok { + raise("TypeError", "wrong argument type %s (expected a Rails::Application)", vm.classOf(args[0]).name) + } + vm.railsApp = av + rails.SetApplication(railsAppAdapter{app: av.app}) + return args[0] + }) + + // Rails.env / Rails.env= — the current environment as an EnvironmentInquirer + // (resolved from RAILS_ENV, then RACK_ENV, then "development", by the meta-gem). + def("env", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &RailsEnvVal{e: rails.Env(), cls: vm.consts["Rails::EnvironmentInquirer"].(*RClass)} + }) + def("env=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + e := rails.SetEnv(railsGroupStr(rackArg(args))) + return &RailsEnvVal{e: e, cls: vm.consts["Rails::EnvironmentInquirer"].(*RClass)} + }) + + // Rails.root / Rails.public_path — filesystem paths delegated to the bound + // application (nil before an application is registered, as in Ruby). + def("root", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.railsApp == nil { + return object.NilV + } + return railsStr(rails.Root()) + }) + def("public_path", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.railsApp == nil { + return object.NilV + } + return railsStr(rails.PublicPath()) + }) + + // Rails.configuration — the application's configuration object, resolved + // through the meta-gem's App seam and re-wrapped as the Ruby configuration; nil + // before an application is registered (as in Ruby). + def("configuration", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.railsApp == nil { + return object.NilV + } + appCfg := rails.Configuration().(*railties.ApplicationConfiguration) + return &RailsConfigVal{ + app: appCfg, + eng: appCfg.EngineConfiguration, + cfg: appCfg.EngineConfiguration.Configuration, + cls: vm.consts["Rails::Railtie::Configuration"].(*RClass), + } + }) + + // Rails.autoloaders / Rails.logger / Rails.cache — opaque any-typed slots the + // meta-gem stores; a Ruby value set through the setter round-trips, otherwise + // nil. Cache falls back to the application's store (railties vends none, so + // nil) when no explicit store was set. + def("autoloaders", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return railsAny(rails.Autoloaders()) + }) + def("logger", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return railsAny(rails.Logger()) + }) + def("logger=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + rails.SetLogger(rackArg(args)) + return rackArg(args) + }) + def("cache", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return railsAny(rails.Cache()) + }) + def("cache=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + rails.SetCache(rackArg(args)) + return rackArg(args) + }) + + // Rails.groups(*extra) — the ordered, de-duplicated group list for the current + // environment (default, env, extras, RAILS_GROUPS), as Ruby Strings. + def("groups", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + extra := make([]string, len(args)) + for i, a := range args { + extra[i] = railsGroupStr(a) + } + return thorStrArray(rails.Groups(extra...)) + }) + + // Rails.version / Rails.gem_version — the targeted Rails release as a String. + def("version", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return railsStr(rails.Version()) + }) + def("gem_version", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return railsStr(rails.GemVersion()) + }) + + // Rails.backtrace_cleaner / Rails.error — the shared ActiveSupport singletons + // (BacktraceCleaner / ErrorReporter). Those concrete objects are owned by + // go-ruby-activesupport and not yet surfaced as Ruby classes, so the accessors + // return nil until that binding lands. + def("backtrace_cleaner", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NilV + }) + def("error", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NilV + }) + + vm.registerRailsVersion(mod) + vm.registerRailsEnvironmentInquirer(mod) + + // require "rails/all" pulls in every shipped framework component. The + // featureHooks map is already created by registerPrime (which runs first). + vm.featureHooks["rails/all"] = vm.installRailsAll +} + +// registerRailsVersion installs Rails::VERSION, the module mirroring Ruby's +// Rails::VERSION with its STRING / MAJOR / MINOR / TINY / PRE constants. +func (vm *VM) registerRailsVersion(mod *RClass) { + ver := newClass("Rails::VERSION", nil) + ver.isModule = true + ver.consts["STRING"] = object.NewString(rails.VERSION.STRING()) + ver.consts["MAJOR"] = object.IntValue(int64(rails.VERSION.Major)) + ver.consts["MINOR"] = object.IntValue(int64(rails.VERSION.Minor)) + ver.consts["TINY"] = object.IntValue(int64(rails.VERSION.Tiny)) + ver.consts["PRE"] = railsPre(rails.VERSION.Pre) + mod.consts["VERSION"] = ver + vm.consts["Rails::VERSION"] = ver +} + +// registerRailsEnvironmentInquirer installs Rails::EnvironmentInquirer, the +// Rails::StringInquirer subclass Rails.env returns: the generic `name?` predicate +// (via method_missing) plus the environment-specific `local?` (development/test), +// and the String coercions (to_str / ==). +func (vm *VM) registerRailsEnvironmentInquirer(mod *RClass) { + super := vm.consts["Rails::StringInquirer"].(*RClass) + c := newClass("Rails::EnvironmentInquirer", super) + mod.consts["EnvironmentInquirer"] = c + vm.consts["Rails::EnvironmentInquirer"] = c + + envOf := func(self object.Value) *RailsEnvVal { return self.(*RailsEnvVal) } + + c.define("to_str", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return railsStr(envOf(self).e.String()) + }) + c.define("==", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(envOf(self).e.String() == railsGroupStr(rackArg(args))) + }) + c.define("local?", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(envOf(self).e.Local()) + }) + c.define("method_missing", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + name := railsMissingName(args) + if n := len(name); n > 0 && name[n-1] == '?' { + return object.Bool(envOf(self).e.Is(name[:n-1])) + } + raise("NoMethodError", "undefined method '%s' for an instance of Rails::EnvironmentInquirer", name) + return object.NilV + }) +} + +// installRailsAll is the rails/all feature hook: `require "rails/all"` loads the +// whole framework, so it triggers a require of each shipped (Available) component +// whose feature the VM already provides. Components without a provided feature yet +// (their bindings not landed) are skipped, so the aggregate tracks the catalog's +// Available set as component bindings arrive. +func (vm *VM) installRailsAll() { + for _, c := range rails.AvailableComponents() { + feat := railsComponentFeature(c.Name) + if providedFeatures[feat] { + vm.doRequire(feat, false) + } + } +} diff --git a/internal/vm/rails_meta_bind.go b/internal/vm/rails_meta_bind.go new file mode 100644 index 00000000..123e2c87 --- /dev/null +++ b/internal/vm/rails_meta_bind.go @@ -0,0 +1,100 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + + rails "github.com/go-ruby-rails/rails" + railties "github.com/go-ruby-railties/railties" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the value-model + seam half of the `rails` meta-gem binding +// (require "rails/all" and the top-level Rails.* accessors); rails_meta.go holds +// the module accessor + class registration. The meta-gem itself +// (github.com/go-ruby-rails/rails, package `rails`) ships almost no code of its +// own: it vends the top-level Rails module (Rails.application / env / root / +// logger / cache / configuration / …), the Rails::VERSION constant and the +// machine-readable component catalog, delegating application state to the +// railties Application through its App seam. This binding layers those onto the +// Rails module the railties binding already created (registerRailties), never +// re-creating it. + +// RailsEnvVal wraps a rails.EnvironmentInquirer (Rails::EnvironmentInquirer, a +// Rails::StringInquirer subclass): the value Rails.env returns so `env.production?` +// and `env.local?` read naturally. +type RailsEnvVal struct { + e rails.EnvironmentInquirer + cls *RClass +} + +func (v *RailsEnvVal) ToS() string { return v.e.String() } +func (v *RailsEnvVal) Inspect() string { return "\"" + v.e.String() + "\"" } +func (v *RailsEnvVal) Truthy() bool { return true } + +// railsAppAdapter adapts a railties Application to the meta-gem's rails.App seam, +// so the top-level Rails.root / Rails.public_path / Rails.configuration / +// Rails.autoloaders / Rails.cache accessors — which the meta-gem resolves through +// rails.SetApplication — delegate to the bound railties application. The +// configuration/autoloaders/cache are opaque to the meta-gem (typed as any); +// railties vends a typed configuration but no autoloader set or cache store, so +// those return nil (mirroring the pre-boot Ruby result). +type railsAppAdapter struct{ app *railties.Application } + +func (a railsAppAdapter) Root() string { return a.app.Paths().Root() } +func (a railsAppAdapter) Config() any { return a.app.Config() } +func (a railsAppAdapter) Autoloaders() any { return nil } +func (a railsAppAdapter) Cache() any { return nil } + +// PublicPath returns the first expanded entry of paths["public"], mirroring +// Rails.public_path; it is empty when the application declares no public path. +func (a railsAppAdapter) PublicPath() string { + if p := a.app.Paths().Get("public"); p != nil { + if ex := p.Expanded(); len(ex) > 0 { + return ex[0] + } + } + return "" +} + +// railsAny converts one of the meta-gem's opaque any-typed slots (logger, cache, +// autoloaders) back to a Ruby value: a stored object.Value passes through, and a +// nil (nothing set, or a slot railties does not vend) becomes Ruby nil. +func railsAny(v any) object.Value { + if rv, ok := v.(object.Value); ok { + return rv + } + return object.NilV +} + +// railsPre renders the Rails::VERSION::PRE constant: the pre-release segment as a +// String, or nil when the release is final (Pre == ""), mirroring Ruby's PRE. +func railsPre(s string) object.Value { + if s == "" { + return object.NilV + } + return object.NewString(s) +} + +// railsComponentFeature maps a catalog component name to the Ruby feature name +// `require "rails/all"` loads for it (the component gem's canonical require path): +// railties is reached through "rails", actionpack through "action_controller", +// and every active*/action* component through its underscored form. +func railsComponentFeature(name string) string { + switch name { + case "railties": + return "rails" + case "actionpack": + return "action_controller" + } + for _, p := range []string{"active", "action"} { + if rest := strings.TrimPrefix(name, p); rest != name { + return p + "_" + rest + } + } + return name +} diff --git a/internal/vm/rails_meta_internal_test.go b/internal/vm/rails_meta_internal_test.go new file mode 100644 index 00000000..28b76453 --- /dev/null +++ b/internal/vm/rails_meta_internal_test.go @@ -0,0 +1,276 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "testing" + + rails "github.com/go-ruby-rails/rails" + railties "github.com/go-ruby-railties/railties" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// TestRailsAllFeature covers the `require "rails/all"` provided feature and its +// aggregate hook: the first require returns true, a second returns false, and it +// triggers a require of every shipped component whose feature the VM provides, so +// those features report already-loaded afterwards. +func TestRailsAllFeature(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "rails/all"`, "true\n"}, + {`require "rails/all"; p require "rails/all"`, "false\n"}, + // rails/all pulled the available components in, so re-requiring each of the + // provided-feature components now returns false (already loaded). + {`require "rails/all" +p require "active_support" +p require "active_model" +p require "active_job" +p require "active_storage" +p require "action_cable"`, "false\nfalse\nfalse\nfalse\nfalse\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRailsVersion covers Rails::VERSION and Rails.version / Rails.gem_version: +// the meta-gem targets Rails 8.1.x. +func TestRailsVersion(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "rails/all"; p Rails::VERSION::STRING`, "\"8.1.3\"\n"}, + {`require "rails/all"; p Rails::VERSION::MAJOR`, "8\n"}, + {`require "rails/all"; p Rails::VERSION::MINOR`, "1\n"}, + {`require "rails/all"; p Rails::VERSION::TINY`, "3\n"}, + {`require "rails/all"; p Rails::VERSION::PRE`, "nil\n"}, + {`require "rails/all"; p Rails.version`, "\"8.1.3\"\n"}, + {`require "rails/all"; p Rails.gem_version`, "\"8.1.3\"\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRailsEnvironmentInquirer covers Rails.env and Rails::EnvironmentInquirer: +// the class hierarchy (< Rails::StringInquirer), the dynamic `name?` predicate, +// the environment-specific local?, the String coercions (to_str / ==), and the +// non-predicate method_missing failure. +func TestRailsEnvironmentInquirer(t *testing.T) { + rails.SetEnv("development") + cases := []struct{ src, want string }{ + {`require "rails/all"; p Rails::EnvironmentInquirer < Rails::StringInquirer`, "true\n"}, + {`require "rails/all"; p Rails.env.class`, "Rails::EnvironmentInquirer\n"}, + {`require "rails/all"; p Rails.env.to_s`, "\"development\"\n"}, + {`require "rails/all"; p Rails.env.development?`, "true\n"}, + {`require "rails/all"; p Rails.env.production?`, "false\n"}, + {`require "rails/all"; p Rails.env.local?`, "true\n"}, + {`require "rails/all"; p Rails.env == "development"`, "true\n"}, + {`require "rails/all"; p Rails.env.to_str`, "\"development\"\n"}, + {`require "rails/all"; p("env is #{Rails.env}")`, "\"env is development\"\n"}, + {`require "rails/all"; begin; Rails.env.bogus; rescue NoMethodError; puts "nomethod"; end`, "nomethod\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRailsEnvSetterAndProduction covers Rails.env= (setting a non-local +// environment) and its local? / predicate behaviour, then restores development. +func TestRailsEnvSetterAndProduction(t *testing.T) { + defer rails.SetEnv("development") + cases := []struct{ src, want string }{ + {`require "rails/all"; Rails.env = "production"; p Rails.env.production?`, "true\n"}, + {`require "rails/all"; Rails.env = "production"; p Rails.env.local?`, "false\n"}, + {`require "rails/all"; Rails.env = "test"; p Rails.env.local?`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRailsGroups covers Rails.groups: the ordered, de-duplicated group list for +// the current environment, with and without positional extras. +func TestRailsGroups(t *testing.T) { + rails.SetEnv("development") + cases := []struct{ src, want string }{ + {`require "rails/all"; p Rails.groups`, "[\"default\", \"development\"]\n"}, + {`require "rails/all"; p Rails.groups(:assets)`, "[\"default\", \"development\", \"assets\"]\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRailsApplicationAccessors covers the application-state accessors: before an +// application is registered they are nil; once Rails.application= binds a railties +// Application they resolve against it (application / root / public_path / +// configuration); assigning nil clears it again. +func TestRailsApplicationAccessors(t *testing.T) { + cases := []struct{ src, want string }{ + // Before boot: nil / nil / nil / nil. + {`require "rails/all" +p Rails.application +p Rails.root +p Rails.public_path +p Rails.configuration`, "nil\nnil\nnil\nnil\n"}, + // Bound application: the accessors resolve against it. + {`require "rails/all" +class Blog < Rails::Application; end +app = Blog.new("Blog", "/srv/blog") +app.paths.add "public", with: "public" +Rails.application = app +p Rails.application.equal?(app) +p Rails.root +p Rails.public_path +p Rails.configuration.is_a?(Rails::Railtie::Configuration)`, + "true\n\"/srv/blog\"\n\"/srv/blog/public\"\ntrue\n"}, + // The bound configuration is the application's own config (round-trips a + // value written through it). + {`require "rails/all" +app = Rails::Application.new("Shop", "/srv/shop") +Rails.application = app +Rails.configuration.foo = 42 +p app.config.foo`, "42\n"}, + // Assigning nil clears the registration. + {`require "rails/all" +app = Rails::Application.new("Tmp", "/tmp/app") +Rails.application = app +Rails.application = nil +p Rails.application +p Rails.root`, "nil\nnil\n"}, + // A non-Application value is rejected. + {`require "rails/all"; begin; Rails.application = 5; rescue TypeError; puts "type"; end`, "type\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRailsLoggerCacheAndStubs covers Rails.logger / Rails.cache (opaque any-typed +// slots that round-trip a Ruby value through their setters), the autoloaders +// accessor (nil — railties vends none), and the backtrace_cleaner / error stubs. +func TestRailsLoggerCacheAndStubs(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "rails/all"; l = Object.new; Rails.logger = l; p Rails.logger.equal?(l)`, "true\n"}, + {`require "rails/all"; c = Object.new; Rails.cache = c; p Rails.cache.equal?(c)`, "true\n"}, + {`require "rails/all"; p Rails.autoloaders`, "nil\n"}, + {`require "rails/all"; p Rails.backtrace_cleaner`, "nil\n"}, + {`require "rails/all"; p Rails.error`, "nil\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRailsSetterArity covers the arity guard on the module setters: invoking one +// with no argument (via send) raises ArgumentError. +func TestRailsSetterArity(t *testing.T) { + setters := []string{"application=", "env=", "logger=", "cache="} + for _, s := range setters { + src := `require "rails/all"; begin; Rails.send(:"` + s + `"); rescue ArgumentError; puts "arity"; end` + if got := eval(t, src); got != "arity\n" { + t.Errorf("setter %q: got=%q want=%q", s, got, "arity\n") + } + } +} + +// TestRailsComponentFeature covers the catalog-component → require-feature mapping +// for every shape: the railties and actionpack special cases, the active*/action* +// underscore split, and the pass-through fallback. +func TestRailsComponentFeature(t *testing.T) { + cases := map[string]string{ + "railties": "rails", + "actionpack": "action_controller", + "activesupport": "active_support", + "activemodel": "active_model", + "actionview": "action_view", + "actioncable": "action_cable", + "foobar": "foobar", + } + for in, want := range cases { + if got := railsComponentFeature(in); got != want { + t.Errorf("railsComponentFeature(%q) = %q, want %q", in, got, want) + } + } +} + +// TestRailsPreAndAny covers the two small value helpers: railsPre (empty → nil, +// non-empty → String) and railsAny (a stored Ruby value passes through, nil → +// Ruby nil). +func TestRailsPreAndAny(t *testing.T) { + if v := railsPre(""); !object.IsNil(v) { + t.Errorf("railsPre(\"\") = %v, want nil", v) + } + if v := railsPre("rc1"); v.ToS() != "rc1" { + t.Errorf("railsPre(\"rc1\") = %q, want \"rc1\"", v.ToS()) + } + s := object.NewString("x") + if v := railsAny(s); v != s { + t.Errorf("railsAny(str) did not pass through") + } + if v := railsAny(nil); !object.IsNil(v) { + t.Errorf("railsAny(nil) = %v, want nil", v) + } + // A non-object.Value any (e.g. a bare Go value) also degrades to nil. + if v := railsAny(42); !object.IsNil(v) { + t.Errorf("railsAny(42) = %v, want nil", v) + } +} + +// TestRailsAppAdapter covers the App-seam adapter directly: Root / Config / +// Autoloaders / Cache and both PublicPath branches (a declared public path, and +// an application without one). +func TestRailsAppAdapter(t *testing.T) { + app := railties.NewApplication("Blog", "/srv/blog") + app.Paths().Add("public", railties.PathOpts{With: []string{"public"}}) + ad := railsAppAdapter{app: app} + if ad.Root() != "/srv/blog" { + t.Errorf("Root() = %q, want /srv/blog", ad.Root()) + } + if ad.Config() == nil { + t.Error("Config() = nil, want non-nil") + } + if ad.Autoloaders() != nil { + t.Errorf("Autoloaders() = %v, want nil", ad.Autoloaders()) + } + if ad.Cache() != nil { + t.Errorf("Cache() = %v, want nil", ad.Cache()) + } + if got := ad.PublicPath(); got != "/srv/blog/public" { + t.Errorf("PublicPath() = %q, want /srv/blog/public", got) + } + bare := railsAppAdapter{app: railties.NewApplication("X", "/x")} + if got := bare.PublicPath(); got != "" { + t.Errorf("PublicPath() without public path = %q, want empty", got) + } +} + +// TestRailsEnvValFuncs covers the RailsEnvVal display methods (ToS / Inspect / +// Truthy). +func TestRailsEnvValFuncs(t *testing.T) { + v := &RailsEnvVal{e: rails.NewEnvironmentInquirer("staging")} + if v.ToS() != "staging" { + t.Errorf("ToS() = %q", v.ToS()) + } + if v.Inspect() != "\"staging\"" { + t.Errorf("Inspect() = %q", v.Inspect()) + } + if !v.Truthy() { + t.Error("Truthy() = false") + } +} diff --git a/internal/vm/require.go b/internal/vm/require.go index 1ce3dd7d..8feb20ba 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -79,6 +79,7 @@ var providedFeatures = map[string]bool{ "active_storage": true, "activestorage": true, "action_cable": true, "actioncable": true, "rails": true, "rails/railtie": true, "rails/engine": true, "rails/application": true, + "rails/all": true, "devise": true, "hanami": true, "hanami/router": true, "hanami/action": true, "warden": true, "omniauth": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 94d4017c..029df4a8 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -283,6 +283,7 @@ type VM struct { cACChannelBase *RClass // ActionCable::Channel::Base, the superclass a subscription's channel subclass extends, backed by go-ruby-actioncable acServer object.Value // memoized ActionCable.server singleton (an ActionCable::Server over an in-process async adapter) railtieSeams map[any]*railtieSeam // per-railtie/engine/app deferred initializer blocks, keyed by the library ctx object; run inline by the RunInitializer seam during Application#initialize! + railsApp *RailsAppVal // the Ruby Application registered via Rails.application= (the rails meta-gem's top-level app); nil before boot deviseConfig *DeviseConfig // the shared Devise.config the DatabaseAuthenticatable Warden strategy authenticates against cHanamiRouter *RClass // Hanami::Router (require "hanami/router"), backed by go-ruby-hanami; wraps a *hanami.Router cHanamiAction *RClass // Hanami::Action (require "hanami/action"), the action-lifecycle superclass a user subclasses