From e0295b8e69baedccd48a44a8c0746e15f2863c94 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 23:04:19 +0200 Subject: [PATCH 1/5] Bind the pure-Go rails meta-gem: top-level Rails.* accessors + rails/all Layer github.com/go-ruby-rails/rails onto the railties binding already in place. The `rails` gem ships almost no code of its own: it ties the framework components together and vends the top-level Rails module. This extends the Rails module registerRailties created (never recreating it) with: - The singleton accessors: Rails.application / application=, env / env=, root, public_path, configuration, autoloaders, logger / logger=, cache / cache=, groups, version / gem_version, backtrace_cleaner, error. - Rails::VERSION (STRING/MAJOR/MINOR/TINY/PRE; targets 8.1.3) and Rails::EnvironmentInquirer (< Rails::StringInquirer) so Rails.env.production? and Rails.env.local? read naturally. - require "rails/all", wired as a feature hook that triggers a require of every shipped (Available) catalog component whose feature the VM provides. Application state (root/public_path/configuration/autoloaders/cache) delegates to the meta-gem, which resolves it against the bound railties Application through its App seam, bridged by railsAppAdapter. Rails.application keeps returning the Ruby Application wrapper (*RailsAppVal) Ruby registered. RailsEnvVal is added to classOf and to hasCustomEq (so `Rails.env == "production"` dispatches the wrapper's == rather than pointer identity). "rails/all" is added to providedFeatures; "rails" stays owned by railties. 100% coverage of the new files; full internal/vm + internal/object suites green; gofmt/vet clean; builds on all six 64-bit Linux arches. Co-Authored-By: Claude Opus 4.8 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/digest_bind.go | 5 + internal/vm/object_model.go | 2 + internal/vm/rails_meta.go | 237 ++++++++++++++++++++ internal/vm/rails_meta_bind.go | 100 +++++++++ internal/vm/rails_meta_internal_test.go | 276 ++++++++++++++++++++++++ internal/vm/require.go | 1 + internal/vm/vm.go | 1 + 10 files changed, 626 insertions(+) create mode 100644 internal/vm/rails_meta.go create mode 100644 internal/vm/rails_meta_bind.go create mode 100644 internal/vm/rails_meta_internal_test.go 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 From e1c749555f8f273a02ab890ce9b66cfb7dc9fab4 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 23:09:59 +0200 Subject: [PATCH 2/5] Bind Action Mailer (go-ruby-actionmailer) as a native module Add `require "action_mailer"` support backed by github.com/go-ruby-actionmailer/actionmailer, so mailer classes compose and deliver mail with no .rb file, mirroring the existing Mail / ActiveJob bindings. Surface (ActionMailer::Base subclass model): - class DSL: `default`, `delivery_method=`/`delivery_method`, `perform_deliveries=`, `raise_delivery_errors=`, `register_interceptor`, `register_observer`, the shared `deliveries` sink and the `renderer=`/ `enqueuer=` injection points; - each mailer action method (`def welcome`) becomes a class method via the method_added hook, returning an ActionMailer::MessageDelivery (`deliver_now` / `deliver` / `deliver_later` / `message`); - the mailer instance surface a `def welcome` body runs against: `mail(...)`, `attachments` (regular + `attachments.inline`) and `headers(hash)`. Four Ruby-dispatch seams, all run INLINE under the GVL: - Action: the mailer method body is sent to a freshly allocated mailer instance bound to the library *Mailer; a raise maps to an error the delivery proxy re-raises faithfully; - RenderBody: body rendering sends #render to the configured Ruby renderer (Action View in the consolidated binary; a stub in tests); a nil renderer or nil result skips the format; - DeliveryMethod: defaults to the :test method (append to ActionMailer::Base.deliveries); a #deliver object plugs in a custom transport; - EnqueueJob: deliver_later sends #enqueue to the configured Ruby enqueuer (Active Job in the consolidated binary; a stub in tests), else runs inline. 100% coverage of the new files (compose/alternative/related/mixed, attachments, interceptors/observers, deliver_now/later, delivery-error raise paths, all seams via injected stubs). gofmt + go vet clean; builds on all six 64-bit arches. Co-Authored-By: Claude Opus 4.8 --- go.mod | 1 + go.sum | 2 + internal/vm/actionmailer.go | 305 ++++++++++++++++++ internal/vm/actionmailer_bind.go | 518 +++++++++++++++++++++++++++++++ internal/vm/actionmailer_test.go | 494 +++++++++++++++++++++++++++++ internal/vm/builtins.go | 1 + internal/vm/object_model.go | 4 + internal/vm/require.go | 1 + internal/vm/vm.go | 9 + 9 files changed, 1335 insertions(+) create mode 100644 internal/vm/actionmailer.go create mode 100644 internal/vm/actionmailer_bind.go create mode 100644 internal/vm/actionmailer_test.go diff --git a/go.mod b/go.mod index cfca96e2..4d548244 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-ruby-abbrev/abbrev v0.0.0-20260629150957-97117892cd38 github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 + github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917 github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a github.com/go-ruby-activerecord/activerecord v0.0.0-20260702222646-da57bd9e07f6 diff --git a/go.sum b/go.sum index fdcd8f98..262bf56d 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe h1:oYFDl+kcAkTOX github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe/go.mod h1:Bh8Vr5Vy9UO7JqU5f6myUOWl0jjnnvtlGdIVTBbdLRw= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 h1:VAFOmKa1u2HKA2LFQhelusPVIAtgp+wAOkXl8IjURbQ= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783/go.mod h1:5DS3icNZKImRwvDP7Kf9IHv+9d6EYtcvRpBALgh/s/Y= +github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917 h1:f1xzBCccdUn49fRLh8XBn/d9OHpcrNa46w6aW0hGjyY= +github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917/go.mod h1:CgixBW5KpbPEx+8SCZJQbeVa2WLNB7ObGRRCsjDEY+Y= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 h1:vRJAWboG75rQWpfTiNQ8XkFRJ1Am9RNKdRKSlTLQBiA= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4/go.mod h1:HBZ9KJKoGTzo4h6/5+gaYyzMYWQtucT660mE68zHZGw= github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a h1:d8kCh4DxIJEQJhd/ZD6niTnxEzWgnZ5NlOmxfdBRDZQ= diff --git a/internal/vm/actionmailer.go b/internal/vm/actionmailer.go new file mode 100644 index 00000000..5dfd3a48 --- /dev/null +++ b/internal/vm/actionmailer.go @@ -0,0 +1,305 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerActionMailer installs the Action Mailer surface (require +// "action_mailer"), backed by the pure-Go engine of +// github.com/go-ruby-actionmailer/actionmailer: +// +// - ActionMailer::Base, the class every `class MyMailer < ActionMailer::Base` +// subclasses: the class-level DSL (default, delivery_method=, +// perform_deliveries=, raise_delivery_errors=, register_interceptor / +// register_observer), the shared deliveries Array, and the render/enqueue +// injection seams (renderer= / enqueuer=). Each mailer action method +// (`def welcome`) becomes callable as a class method via the method_added +// hook, returning an ActionMailer::MessageDelivery; +// - ActionMailer::MessageDelivery, the lazy proxy `MyMailer.welcome(user)` +// returns: deliver_now / deliver_later / message; +// - the mailer instance surface a `def welcome` body runs against: mail(...), +// attachments (attachments[name] = data / attachments.inline[name] = data) +// and headers(hash). +// +// The message composition, the multipart MIME assembly, the delivery-method +// registry and the interceptor/observer hooks live in the library; this file is +// the class and method wiring, and actionmailer_bind.go holds the four Ruby +// seams (Action, RenderBody, DeliveryMethod, EnqueueJob) and the Ruby <-> +// actionmailer value conversions. Every seam runs INLINE on the VM goroutine +// under the GVL, so a mailer action composes synchronously and (with the default +// :test delivery) deliver_now appends to ActionMailer::Base.deliveries. +func (vm *VM) registerActionMailer() { + mod := newClass("ActionMailer", nil) + mod.isModule = true + vm.consts["ActionMailer"] = mod + + vm.amDeliveries = object.NewArrayFromSlice(nil) + + base := newClass("ActionMailer::Base", vm.cObject) + mod.consts["Base"] = base + vm.consts["ActionMailer::Base"] = base + vm.cActionMailerBase = base + + del := newClass("ActionMailer::MessageDelivery", vm.cObject) + mod.consts["MessageDelivery"] = del + vm.consts["ActionMailer::MessageDelivery"] = del + vm.cActionMailerDelivery = del + vm.registerActionMailerDelivery(del) + + // An internal proxy class for the `attachments` accessor (mirroring + // Mail::AttachmentsList); not a documented constant, but carried on the value + // so classOf reports it. + att := newClass("ActionMailer::Base::Attachments", vm.cObject) + base.consts["Attachments"] = att + vm.cActionMailerAttachments = att + vm.registerActionMailerAttachments(att) + + vm.registerActionMailerDSL(base) + vm.registerActionMailerInstance(base) +} + +// registerActionMailerDSL installs the class-level Action Mailer DSL on +// ActionMailer::Base (inherited by every mailer subclass): the method_added hook +// that turns each mailer action method into a class method, the default / +// delivery / hook declarations, the shared deliveries accessor and the +// render/enqueue injection seams. +func (vm *VM) registerActionMailerDSL(base *RClass) { + sm := func(name string, fn NativeFn) { + base.smethods[name] = &Method{name: name, owner: base, native: fn} + } + + // method_added(:name) fires for every instance method a mailer subclass + // defines; each becomes a class method that runs the action and returns a + // MessageDelivery (the analogue of Rails resolving action_methods lazily). + sm("method_added", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if cls, ok := self.(*RClass); ok && len(args) > 0 { + vm.amDefineAction(cls, amKeyStr(args[0])) + } + return object.NilV + }) + + // default(key: value, …) records the class's default headers/params, merged + // down the ancestor chain at delivery time. + sm("default", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := vm.amDefOf(self.(*RClass)) + if h := amTrailingHash(args); h != nil { + for _, k := range h.Keys { + v, _ := h.Get(k) + d.defaults[amKeyStr(k)] = v.ToS() + } + } + return object.NilV + }) + + // delivery_method = :test | a #deliver object. + sm("delivery_method=", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + choice := amArg(args) + vm.amValidateDelivery(choice) + vm.amDefOf(self.(*RClass)).delivery = choice + return choice + }) + sm("delivery_method", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if d := vm.amDefs[self.(*RClass)]; d != nil && d.delivery != nil { + return d.delivery + } + return object.Symbol("test") + }) + + // perform_deliveries= / raise_delivery_errors= gate delivery. + sm("perform_deliveries=", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := amArg(args).Truthy() + vm.amDefOf(self.(*RClass)).perform = &b + return amArg(args) + }) + sm("perform_deliveries", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if d := vm.amDefs[self.(*RClass)]; d != nil && d.perform != nil { + return object.Bool(*d.perform) + } + return object.Bool(true) + }) + sm("raise_delivery_errors=", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := amArg(args).Truthy() + vm.amDefOf(self.(*RClass)).raiseErrors = &b + return amArg(args) + }) + sm("raise_delivery_errors", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if d := vm.amDefs[self.(*RClass)]; d != nil && d.raiseErrors != nil { + return object.Bool(*d.raiseErrors) + } + return object.Bool(true) + }) + + // register_interceptor / register_observer add a delivery hook. + sm("register_interceptor", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := vm.amDefOf(self.(*RClass)) + d.interceptors = append(d.interceptors, amArg(args)) + return object.NilV + }) + sm("register_observer", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := vm.amDefOf(self.(*RClass)) + d.observers = append(d.observers, amArg(args)) + return object.NilV + }) + + // deliveries / deliveries= expose the shared test-delivery sink. + sm("deliveries", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.amDeliveries + }) + sm("deliveries=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if arr, ok := amArg(args).(*object.Array); ok { + vm.amDeliveries = arr + } else { + vm.amDeliveries = object.NewArrayFromSlice(nil) + } + return vm.amDeliveries + }) + + // renderer= / enqueuer= inject the Action View and Active Job seams (wired to + // the bound go-ruby-actionview / go-ruby-activejob in the consolidated binary, + // or a stub in tests). + sm("renderer=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + vm.amRenderer = amArg(args) + return vm.amRenderer + }) + sm("renderer", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.amRenderer == nil { + return object.NilV + } + return vm.amRenderer + }) + sm("enqueuer=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + vm.amEnqueuer = amArg(args) + return vm.amEnqueuer + }) + sm("enqueuer", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.amEnqueuer == nil { + return object.NilV + } + return vm.amEnqueuer + }) +} + +// amDefineAction installs the class method that runs a mailer action: called on a +// mailer class it processes the action with the given arguments and returns the +// ActionMailer::MessageDelivery proxy. Defined once per name (a redefinition of +// the same action keeps the first, which dispatches to the current instance +// method anyway). +func (vm *VM) amDefineAction(cls *RClass, name string) { + if _, exists := cls.smethods[name]; exists { + return + } + cls.smethods[name] = &Method{name: name, owner: cls, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.amProcess(self.(*RClass), name, args) + }} +} + +// registerActionMailerInstance installs the mailer instance surface a `def +// welcome` body runs against: mail(...), attachments and headers(hash). Each +// resolves the library *Mailer bound to the running action. +func (vm *VM) registerActionMailerInstance(base *RClass) { + base.define("mail", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + m := vm.amMailerFor(self) + if err := m.Mail(vm.amMailOptions(args)); err != nil { + return vm.amRaise(err) + } + return object.NilV + }) + base.define("attachments", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ActionMailerAttachments{a: vm.amMailerFor(self).Attachments(), cls: vm.cActionMailerAttachments} + }) + base.define("headers", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + m := vm.amMailerFor(self) + if h := amTrailingHash(args); h != nil { + hdr := map[string]string{} + for _, k := range h.Keys { + v, _ := h.Get(k) + hdr[amKeyStr(k)] = v.ToS() + } + m.Headers(hdr) + } + return object.NilV + }) +} + +// registerActionMailerDelivery installs the ActionMailer::MessageDelivery proxy +// surface: deliver_now / deliver (immediate), deliver_later (through the enqueue +// seam) and message (the composed Mail::Message). A composition or delivery error +// captured on the delivery re-raises here as its Ruby exception. +func (vm *VM) registerActionMailerDelivery(cls *RClass) { + self := func(v object.Value) *ActionMailerDelivery { return v.(*ActionMailerDelivery) } + + deliverNow := func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + d := self(v).d + if err := d.DeliverNow(); err != nil { + return vm.amRaise(err) + } + msg, _ := d.Message() + return amMessageValue(msg) + } + cls.define("deliver_now", deliverNow) + cls.define("deliver", deliverNow) + + cls.define("deliver_later", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + if err := self(v).d.DeliverLater(); err != nil { + return vm.amRaise(err) + } + return object.NilV + }) + cls.define("message", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + msg, err := self(v).d.Message() + if err != nil { + return vm.amRaise(err) + } + return amMessageValue(msg) + }) +} + +// registerActionMailerAttachments installs the `attachments` proxy surface: +// attachments[name] = data (a regular attachment), attachments.inline (the inline +// sub-proxy) and attachments[name] (the stored bytes, or nil). +func (vm *VM) registerActionMailerAttachments(cls *RClass) { + self := func(v object.Value) *ActionMailerAttachments { return v.(*ActionMailerAttachments) } + + cls.define("[]=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + a := self(v) + name := args[0].ToS() + data, ct := amAttachmentData(args[1]) + var at = a.a.Set(name, data) + if a.inline { + at = a.a.SetInline(name, data) + } + if ct != "" { + at.ContentType = ct + } + return args[1] + }) + cls.define("[]", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + at := self(v).a.Get(args[0].ToS()) + if at == nil { + return object.NilV + } + return object.NewStringBytes(at.Data) + }) + cls.define("inline", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + a := self(v) + return &ActionMailerAttachments{a: a.a, inline: true, cls: a.cls} + }) +} + +// amArg returns the first argument, or Ruby nil when none was given. +func amArg(args []object.Value) object.Value { + if len(args) == 0 { + return object.NilV + } + return args[0] +} diff --git a/internal/vm/actionmailer_bind.go b/internal/vm/actionmailer_bind.go new file mode 100644 index 00000000..1ab6585e --- /dev/null +++ b/internal/vm/actionmailer_bind.go @@ -0,0 +1,518 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + + actionmailer "github.com/go-ruby-actionmailer/actionmailer" + mail "github.com/go-ruby-mail/mail" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the binding between rbgo's Ruby object graph and the pure-Go +// Action Mailer engine of github.com/go-ruby-actionmailer/actionmailer. The +// library owns message composition (headers, the multipart/alternative -> +// related -> mixed MIME assembly), the delivery-method registry and the +// interceptor/observer hooks; rbgo supplies the four interpreter-dependent seams +// it documents, each dispatched at the Ruby level and run INLINE under the GVL: +// +// - Action: a mailer method's body is Ruby, so the library's per-action closure +// sends the mailer action (welcome, …) to a freshly allocated Ruby mailer +// instance bound to the library *Mailer, mapping a raised Ruby exception into +// an amError the delivery proxy re-raises faithfully (amProcess / amInvokeAction). +// - RenderBody: body rendering is Action View, so the seam sends #render to the +// configured Ruby renderer (ActionMailer::Base.renderer=, wired to the bound +// go-ruby-actionview in the consolidated binary; an injected stub in tests); +// a nil renderer or a nil result means "no template for this format" and the +// format is skipped, exactly as a missing .text/.html template does. +// - DeliveryMethod: the default appends the composed message to the shared +// ActionMailer::Base.deliveries Array (the :test method); a Ruby object that +// responds to #deliver plugs in as a custom transport (delivery_method=). +// - EnqueueJob: deliver_later's Active Job seam sends #enqueue to the configured +// Ruby enqueuer (wired to the bound go-ruby-activejob in the consolidated +// binary; an injected stub in tests) with the delivery as its block; with no +// enqueuer the delivery runs inline. + +// amError carries a raised Ruby exception's class through the Go error the +// Action / RenderBody / delivery seams return, so the composed-message error the +// library captures on a MessageDelivery re-raises as the original Ruby exception +// (amRaise), mirroring how ActionMailer surfaces a composition/delivery failure +// from deliver_now / deliver_later / message. +type amError struct { + cls *RClass + msg string +} + +func (e *amError) Error() string { return e.msg } + +// ActionMailerDelivery is the lazy delivery proxy `MyMailer.welcome(user)` +// returns — a Ruby ActionMailer::MessageDelivery wrapping the library +// *MessageDelivery — responding to deliver_now / deliver_later / message. +type ActionMailerDelivery struct { + d *actionmailer.MessageDelivery + cls *RClass +} + +func (d *ActionMailerDelivery) ToS() string { return "#" } +func (d *ActionMailerDelivery) Inspect() string { return d.ToS() } +func (d *ActionMailerDelivery) Truthy() bool { return true } + +// ActionMailerAttachments is the mailer's `attachments` proxy (mirroring +// Mail::AttachmentsList): attachments[name] = data adds a regular attachment, +// attachments.inline[name] = data adds an inline one. It wraps the library +// *Attachments of the action's *Mailer; inline records which sub-proxy this is. +type ActionMailerAttachments struct { + a *actionmailer.Attachments + inline bool + cls *RClass +} + +func (a *ActionMailerAttachments) ToS() string { return "#" } +func (a *ActionMailerAttachments) Inspect() string { return a.ToS() } +func (a *ActionMailerAttachments) Truthy() bool { return true } + +// --- class-level declaration model ------------------------------------------ + +// amDef is the class-level Action Mailer configuration a mailer class accumulates +// as its body runs the DSL (default, delivery_method=, perform_deliveries=, +// raise_delivery_errors=, register_interceptor / register_observer). Each mailer +// class owns one; amBuildBase flattens a class and its ancestors' defs into a +// live *actionmailer.Base at delivery time, so a subclass inherits its parents' +// defaults, delivery method and hooks (ancestor first, subclass overrides). +type amDef struct { + defaults map[string]string + delivery object.Value // :test symbol or a Ruby object responding to #deliver; nil = inherit/default + perform *bool + raiseErrors *bool + interceptors []object.Value + observers []object.Value +} + +// amDefOf returns the amDef for a mailer class, creating it on first use. +func (vm *VM) amDefOf(cls *RClass) *amDef { + if vm.amDefs == nil { + vm.amDefs = map[*RClass]*amDef{} + } + d, ok := vm.amDefs[cls] + if !ok { + d = &amDef{defaults: map[string]string{}} + vm.amDefs[cls] = d + } + return d +} + +// amBuildBase assembles a *actionmailer.Base for a mailer class from its +// declaration chain (ActionMailer::Base-most ancestor first, the mailer last so +// it overrides), wiring the RenderBody and EnqueueJob Ruby-dispatch seams and the +// delivery method. It is built fresh per delivery, so the latest class config +// always applies; Now is left nil so composed output carries no non-deterministic +// Date header. +func (vm *VM) amBuildBase(cls *RClass) *actionmailer.Base { + b := actionmailer.New(cls.name) + b.Now = nil + b.RenderBody = vm.amRenderBody() + b.EnqueueJob = vm.amEnqueueJob() + + var chain []*RClass + for c := cls; c != nil && c != vm.cObject; c = c.super { + chain = append(chain, c) + } + var delivery object.Value + for i := len(chain) - 1; i >= 0; i-- { + d := vm.amDefs[chain[i]] + if d == nil { + continue + } + for k, v := range d.defaults { + b.Default(k, v) + } + if d.delivery != nil { + delivery = d.delivery + } + if d.perform != nil { + b.PerformDeliveries = *d.perform + } + if d.raiseErrors != nil { + b.RaiseDeliveryErrors = *d.raiseErrors + } + for _, in := range d.interceptors { + b.RegisterInterceptor(&amRubyInterceptor{vm: vm, obj: in}) + } + for _, ob := range d.observers { + b.RegisterObserver(&amRubyObserver{vm: vm, obj: ob}) + } + } + b.DeliveryMethod = vm.amDeliveryMethod(delivery) + return b +} + +// --- action dispatch -------------------------------------------------------- + +// amProcess runs a mailer action: it builds the class's *actionmailer.Base, +// registers the Ruby action seam and processes the action with the call's +// arguments, returning the Ruby ActionMailer::MessageDelivery proxy. The library +// composes the message eagerly (running the Ruby action body inline here) and +// captures any composition error on the delivery for deliver_now/message to raise. +func (vm *VM) amProcess(cls *RClass, action string, args []object.Value) object.Value { + b := vm.amBuildBase(cls) + b.Register(action, func(m *actionmailer.Mailer, params ...any) error { + inst := &RObject{class: cls, ivars: map[string]object.Value{}} + vm.amBind(inst, m) + rubyArgs := make([]object.Value, len(params)) + for i, p := range params { + rubyArgs[i] = p.(object.Value) + } + if aerr := vm.amInvokeAction(inst, action, rubyArgs); aerr != nil { + return aerr + } + return nil + }) + goArgs := make([]any, len(args)) + for i, a := range args { + goArgs[i] = a + } + return &ActionMailerDelivery{d: b.Process(action, goArgs...), cls: vm.cActionMailerDelivery} +} + +// amBind associates a Ruby mailer instance with the library *Mailer of its +// running action, so the mail / attachments / headers instance methods resolve +// the composer for the current invocation. +func (vm *VM) amBind(inst *RObject, m *actionmailer.Mailer) { + if vm.amMailerOf == nil { + vm.amMailerOf = map[*RObject]*actionmailer.Mailer{} + } + vm.amMailerOf[inst] = m +} + +// amMailerFor returns the library *Mailer bound to a mailer instance, raising +// when mail/attachments/headers are called outside a mailer action. +func (vm *VM) amMailerFor(self object.Value) *actionmailer.Mailer { + if o, ok := self.(*RObject); ok { + if m := vm.amMailerOf[o]; m != nil { + return m + } + } + raise("RuntimeError", "no mailer action in progress") + return nil +} + +// amInvokeAction sends the action to the mailer instance, recovering a raised +// Ruby exception into an amError so the library captures it on the delivery +// (mirroring an action that raises during composition). A non-Ruby Go panic +// propagates. +func (vm *VM) amInvokeAction(inst *RObject, action string, args []object.Value) (aerr *amError) { + defer func() { + if r := recover(); r != nil { + e := r.(RubyError) + aerr = &amError{cls: vm.minitestRaisedClass(e), msg: e.Message} + } + }() + vm.send(inst, action, args, nil) + return nil +} + +// amProtect runs f, recovering a raised Ruby exception into an amError so a seam +// invoked deep inside the library (RenderBody, a Ruby delivery method) surfaces +// the failure through the library's error path rather than unwinding across it. +func (vm *VM) amProtect(f func() object.Value) (res object.Value, aerr *amError) { + defer func() { + if r := recover(); r != nil { + e := r.(RubyError) + aerr = &amError{cls: vm.minitestRaisedClass(e), msg: e.Message} + } + }() + return f(), nil +} + +// amRaise re-raises an error the library surfaced as the faithful Ruby exception: +// a seam failure carries its original Ruby class through amError; any other +// (a library sentinel such as ErrNoDeliveryMethod) raises as a RuntimeError. +func (vm *VM) amRaise(err error) object.Value { + var ae *amError + if errors.As(err, &ae) { + return raise(ae.cls.name, "%s", ae.Error()) + } + return raise("RuntimeError", "%s", err.Error()) +} + +// --- RenderBody seam -------------------------------------------------------- + +// amRenderBody builds the body-rendering seam: it sends #render to the configured +// Ruby renderer with the mailer/action/format/locals, returning the rendered +// String. A nil renderer or a nil #render result signals ErrNoTemplate, so that +// format is skipped; a raise during rendering surfaces as an amError the delivery +// proxy re-raises. +func (vm *VM) amRenderBody() actionmailer.RenderBody { + return func(mailer, action, format string, locals map[string]any) (string, error) { + if vm.amRenderer == nil { + return "", actionmailer.ErrNoTemplate + } + h := object.NewHash() + h.Set(object.Symbol("mailer"), object.NewString(mailer)) + h.Set(object.Symbol("action"), object.NewString(action)) + h.Set(object.Symbol("format"), object.NewString(format)) + h.Set(object.Symbol("locals"), amLocalsToRuby(locals)) + res, aerr := vm.amProtect(func() object.Value { + return vm.send(vm.amRenderer, "render", []object.Value{h}, nil) + }) + if aerr != nil { + return "", aerr + } + if object.IsNil(res) { + return "", actionmailer.ErrNoTemplate + } + return res.ToS(), nil + } +} + +// amLocalsToRuby maps the render seam's locals back into a Ruby Hash (the values +// are the Ruby Values captured from the mail(locals:) call). +func amLocalsToRuby(locals map[string]any) object.Value { + h := object.NewHash() + for k, v := range locals { + h.Set(object.NewString(k), v.(object.Value)) + } + return h +} + +// --- EnqueueJob seam -------------------------------------------------------- + +// amEnqueueJob builds the deliver_later Active Job seam: with a configured Ruby +// enqueuer it sends #enqueue with the delivery as its block (the enqueuer may run +// it now or defer it); with no enqueuer the delivery runs inline. A delivery +// error raised by the inline path propagates. +func (vm *VM) amEnqueueJob() func(func() error) error { + return func(job func() error) error { + if vm.amEnqueuer == nil { + return job() + } + blk := &Proc{native: func(_ *VM, _ []object.Value) object.Value { + if err := job(); err != nil { + return vm.amRaise(err) + } + return object.NilV + }} + vm.send(vm.amEnqueuer, "enqueue", nil, blk) + return nil + } +} + +// --- delivery method + interceptor/observer seams --------------------------- + +// amValidateDelivery checks a delivery_method= value at set time (so amDeliveryMethod +// only ever sees a valid choice): :test is the built-in test method, any other +// Symbol is unsupported, and a non-Symbol must respond to #deliver. +func (vm *VM) amValidateDelivery(choice object.Value) { + if sym, ok := choice.(object.Symbol); ok { + if string(sym) != "test" { + raise("ArgumentError", "unsupported delivery_method %q (only :test or a #deliver object)", string(sym)) + } + return + } + if !vm.respondsTo(choice, "deliver") { + raise("ArgumentError", "delivery_method must be :test or respond to #deliver") + } +} + +// amDeliveryMethod resolves a (validated) delivery-method choice: nil or :test +// appends to the shared ActionMailer::Base.deliveries Array; any other value is a +// Ruby object responding to #deliver, plugged in as a custom transport. +func (vm *VM) amDeliveryMethod(choice object.Value) actionmailer.DeliveryMethod { + if choice == nil { + return &amArrayDelivery{vm: vm} + } + if sym, ok := choice.(object.Symbol); ok && string(sym) == "test" { + return &amArrayDelivery{vm: vm} + } + return &amRubyDelivery{vm: vm, obj: choice} +} + +// amArrayDelivery is the :test delivery method: it appends the delivered message +// to the shared ActionMailer::Base.deliveries Array (read through vm so a +// deliveries= reset is honoured). +type amArrayDelivery struct{ vm *VM } + +func (d *amArrayDelivery) Deliver(m *mail.Message) error { + d.vm.amDeliveries.Elems = append(d.vm.amDeliveries.Elems, amMessageValue(m)) + return nil +} + +// amRubyDelivery wraps a Ruby delivery object (delivery_method= obj): Deliver +// sends #deliver with the composed Mail::Message, mapping a raise to an amError. +type amRubyDelivery struct { + vm *VM + obj object.Value +} + +func (d *amRubyDelivery) Deliver(m *mail.Message) error { + _, aerr := d.vm.amProtect(func() object.Value { + return d.vm.send(d.obj, "deliver", []object.Value{amMessageValue(m)}, nil) + }) + if aerr != nil { + return aerr + } + return nil +} + +// amRubyInterceptor wraps a Ruby interceptor (register_interceptor obj): it sends +// #delivering_email with the message just before delivery. +type amRubyInterceptor struct { + vm *VM + obj object.Value +} + +func (i *amRubyInterceptor) DeliveringEmail(m *mail.Message) { + i.vm.send(i.obj, "delivering_email", []object.Value{amMessageValue(m)}, nil) +} + +// amRubyObserver wraps a Ruby observer (register_observer obj): it sends +// #delivered_email with the message just after delivery. +type amRubyObserver struct { + vm *VM + obj object.Value +} + +func (o *amRubyObserver) DeliveredEmail(m *mail.Message) { + o.vm.send(o.obj, "delivered_email", []object.Value{amMessageValue(m)}, nil) +} + +// amMessageValue wraps a library *mail.Message as the Ruby Mail::Message value the +// Mail module already binds, so a delivered message is inspectable in Ruby. +func amMessageValue(m *mail.Message) object.Value { + return &MailMessage{m: mailMsg{m: m}} +} + +// --- mail(...) option + attachment parsing ---------------------------------- + +// amMailOptions maps the keyword Hash of a Ruby mail(...) call into the library +// MailOptions: the recipient lists (to/cc/bcc/reply_to) accept a String or an +// Array, from/subject/content_type/body/formats/locals map to their fields, and +// any other key becomes an extra header. +func (vm *VM) amMailOptions(args []object.Value) actionmailer.MailOptions { + opts := actionmailer.MailOptions{} + h := amTrailingHash(args) + if h == nil { + return opts + } + for _, k := range h.Keys { + v, _ := h.Get(k) + switch amKeyStr(k) { + case "to": + opts.To = amStrList(v) + case "cc": + opts.Cc = amStrList(v) + case "bcc": + opts.Bcc = amStrList(v) + case "reply_to": + opts.ReplyTo = amStrList(v) + case "from": + opts.From = v.ToS() + case "subject": + opts.Subject = v.ToS() + case "content_type": + opts.ContentType = v.ToS() + case "body": + opts.Body = v.ToS() + case "formats": + opts.Formats = amStrList(v) + case "locals": + opts.Locals = amLocalsFromRuby(v) + default: + if opts.Headers == nil { + opts.Headers = map[string]string{} + } + opts.Headers[amKeyStr(k)] = v.ToS() + } + } + return opts +} + +// amLocalsFromRuby maps a Ruby locals Hash into the render seam's locals map, +// keying by the String form of each key and carrying the Ruby Value through. +func amLocalsFromRuby(v object.Value) map[string]any { + h, ok := v.(*object.Hash) + if !ok { + return nil + } + out := map[string]any{} + for _, k := range h.Keys { + val, _ := h.Get(k) + out[amKeyStr(k)] = val + } + return out +} + +// amAttachmentData reads the value of an `attachments[name] = …` assignment: a +// bare String is the content; a Hash carries :content (or :data/:body) and an +// optional :content_type / :mime_type override. +func amAttachmentData(v object.Value) (data []byte, contentType string) { + if h, ok := v.(*object.Hash); ok { + if c, ok := amHashFetch(h, "content", "data", "body"); ok { + data = amBytes(c) + } + if ct, ok := amHashFetch(h, "content_type", "mime_type"); ok { + contentType = ct.ToS() + } + return data, contentType + } + return amBytes(v), "" +} + +// amHashFetch returns the first present of the given symbol-or-string keys. +func amHashFetch(h *object.Hash, keys ...string) (object.Value, bool) { + for _, k := range keys { + if v, ok := h.Get(object.Symbol(k)); ok { + return v, true + } + if v, ok := h.Get(object.NewString(k)); ok { + return v, true + } + } + return nil, false +} + +// amTrailingHash returns the trailing keyword Hash of an argument list, or nil. +func amTrailingHash(args []object.Value) *object.Hash { + if len(args) == 0 { + return nil + } + if h, ok := args[len(args)-1].(*object.Hash); ok { + return h + } + return nil +} + +// amStrList coerces a recipient value to a list of Strings: an Array yields each +// element's String, any other value yields a single-element list. +func amStrList(v object.Value) []string { + if arr, ok := v.(*object.Array); ok { + out := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = e.ToS() + } + return out + } + return []string{v.ToS()} +} + +// amKeyStr renders a Hash/option key as its String form (a Symbol without the +// colon). +func amKeyStr(k object.Value) string { + if s, ok := k.(object.Symbol); ok { + return string(s) + } + return k.ToS() +} + +// amBytes returns the raw bytes of a value: a String's content verbatim (so +// binary attachment data round-trips), any other value its String form. +func amBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + return []byte(v.ToS()) +} diff --git a/internal/vm/actionmailer_test.go b/internal/vm/actionmailer_test.go new file mode 100644 index 00000000..8e8b6d17 --- /dev/null +++ b/internal/vm/actionmailer_test.go @@ -0,0 +1,494 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import "testing" + +// amSrc prefixes a program with require "action_mailer". +func amSrc(body string) string { return "require \"action_mailer\"\n" + body } + +// amRendererStub is a program fragment installing a renderer stub that renders a +// text and an html body from the :name local. +const amRendererStub = ` +stub = Object.new +def stub.render(opts) + n = opts[:locals]["name"] + case opts[:format] + when "text" then "Hi #{n}" + when "html" then "

Hi #{n}

" + end +end +ActionMailer::Base.renderer = stub +` + +func amCheck(t *testing.T, src, want string) { + t.Helper() + if got := eval(t, amSrc(src)); got != want { + t.Errorf("src=%q\n got=%q\nwant=%q", src, got, want) + } +} + +// TestActionMailerRequire covers feature registration and the constant tree. +func TestActionMailerRequire(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "action_mailer"`, "true\n"}, + {`require "action_mailer"; p require "action_mailer"`, "false\n"}, + {`p require "actionmailer"`, "true\n"}, + {`require "action_mailer"; p ActionMailer.is_a?(Module)`, "true\n"}, + {`require "action_mailer"; p ActionMailer::Base.is_a?(Class)`, "true\n"}, + {`require "action_mailer"; p ActionMailer::MessageDelivery.is_a?(Class)`, "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) + } + } +} + +// TestActionMailerCompose covers the end-to-end compose+deliver flow: the default +// from, the rendered multipart/alternative body, deliver_now returning the +// message, the shared deliveries Array and #message. +func TestActionMailerCompose(t *testing.T) { + amCheck(t, amRendererStub+` +class UserMailer < ActionMailer::Base + default from: "notifications@example.com" + def welcome(name) + mail(to: "ada@example.com", subject: "Welcome", locals: { name: name }) + end +end +md = UserMailer.welcome("Ada") +p md.class.name +msg = md.deliver_now +p msg.subject +p msg.to +p msg.from +p ActionMailer::Base.deliveries.length +e = md.message.encoded +puts e.include?("multipart/alternative") +puts e.include?("Hi Ada") +puts e.include?("

Hi Ada

") +`, "\"ActionMailer::MessageDelivery\"\n\"Welcome\"\n\"ada@example.com\"\n\"notifications@example.com\"\n1\ntrue\ntrue\ntrue\n") +} + +// TestActionMailerRecipientsAndHeaders covers recipient lists (String and Array), +// an explicit from, a custom header from an unknown mail(...) key and the +// headers(hash) instance method. +func TestActionMailerRecipientsAndHeaders(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def multi + headers("X-Extra" => "e") + mail(from: "s@x.com", to: ["a@x.com", "b@x.com"], cc: "c@x.com", + bcc: ["d@x.com"], reply_to: "r@x.com", subject: "Hi", + "X-Custom" => "v", locals: {}) + end +end +msg = M.multi.deliver_now +p msg.to +p msg.cc +e = msg.encoded +puts e.include?("X-Custom: v") +puts e.include?("X-Extra: e") +puts e.include?("Reply-To: r@x.com") +`, "[\"a@x.com\", \"b@x.com\"]\n\"c@x.com\"\ntrue\ntrue\ntrue\n") +} + +// TestActionMailerExplicitBody covers mail(body:, content_type:) which bypasses +// the RenderBody seam, and a mailer with no renderer configured. +func TestActionMailerExplicitBody(t *testing.T) { + amCheck(t, ` +class Plain < ActionMailer::Base + def note + mail(to: "a@x.com", subject: "N", body: "hello", content_type: "text/plain") + end +end +e = Plain.note.deliver_now.encoded +puts e.include?("hello") +puts e.include?("multipart") == false +`, "true\ntrue\n") +} + +// TestActionMailerNoTemplate covers the RenderBody seam skipping a format the +// renderer has no template for (nil result) and rendering only the other. +func TestActionMailerNoTemplate(t *testing.T) { + amCheck(t, ` +stub = Object.new +def stub.render(opts) + opts[:format] == "text" ? "only text" : nil +end +ActionMailer::Base.renderer = stub +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {}); end +end +e = M.go.deliver_now.encoded +puts e.include?("only text") +puts e.include?("multipart/alternative") == false +`, "true\ntrue\n") +} + +// TestActionMailerFormats covers the mail(formats:) option restricting rendering +// to a single format. +func TestActionMailerFormats(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", formats: ["text"], locals: {name: "Zo"}); end +end +e = M.go.deliver_now.encoded +puts e.include?("Hi Zo") +puts e.include?("

") == false +`, "true\ntrue\n") +} + +// TestActionMailerAttachments covers regular attachments (multipart/mixed), inline +// attachments (multipart/related + Content-ID), the hash form with a content_type +// override and the attachments[name] reader (present and absent). +func TestActionMailerAttachments(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go + attachments["a.txt"] = "plain" + attachments.inline["logo.png"] = "PNGDATA" + attachments["data.bin"] = { content: "raw", mime_type: "application/x-thing" } + p attachments["a.txt"] + p attachments["missing"] + mail(to: "a@x.com", subject: "S", locals: {name: "Q"}) + end +end +e = M.go.deliver_now.encoded +puts e.include?("multipart/mixed") +puts e.include?("multipart/related") +puts e.include?("Content-ID: ") +puts e.include?("application/x-thing") +`, "\"plain\"\nnil\ntrue\ntrue\ntrue\ntrue\n") +} + +// TestActionMailerDeliverLater covers deliver_later run inline (no enqueuer), via +// an enqueuer stub that yields (runs the delivery) and via one that defers it. +func TestActionMailerDeliverLater(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "L"}); end +end +# inline (no enqueuer) +M.go.deliver_later +p ActionMailer::Base.deliveries.length + +# enqueuer that runs immediately +runner = Object.new +def runner.enqueue; yield; end +ActionMailer::Base.enqueuer = runner +M.go.deliver_later +p ActionMailer::Base.deliveries.length + +# enqueuer that defers (never yields) +defer = Object.new +def defer.enqueue; end +ActionMailer::Base.enqueuer = defer +M.go.deliver_later +p ActionMailer::Base.deliveries.length +p ActionMailer::Base.enqueuer.equal?(defer) +`, "1\n2\n2\ntrue\n") +} + +// TestActionMailerInterceptorsObservers covers register_interceptor and +// register_observer hooks firing around delivery. +func TestActionMailerInterceptorsObservers(t *testing.T) { + amCheck(t, amRendererStub+` +$log = [] +icept = Object.new +def icept.delivering_email(m); $log << "before:#{m.subject}"; end +obs = Object.new +def obs.delivered_email(m); $log << "after:#{m.subject}"; end +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "I"}); end +end +ActionMailer::Base.register_interceptor(icept) +ActionMailer::Base.register_observer(obs) +M.go.deliver_now +p $log +`, "[\"before:S\", \"after:S\"]\n") +} + +// TestActionMailerDeliveryMethod covers delivery_method= with :test, the reader, +// a custom #deliver object, and the unsupported-symbol / non-#deliver raises. +func TestActionMailerDeliveryMethod(t *testing.T) { + amCheck(t, amRendererStub+` +$sent = [] +custom = Object.new +def custom.deliver(m); $sent << m.subject; end +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "D"}); end +end +p M.delivery_method +M.delivery_method = :test +p M.delivery_method +M.delivery_method = custom +M.go.deliver_now +p $sent +p ActionMailer::Base.deliveries.length +begin + M.delivery_method = :smtp + M.go.deliver_now +rescue ArgumentError => e + puts "bad-sym" +end +begin + M.delivery_method = Object.new + M.go.deliver_now +rescue ArgumentError + puts "bad-obj" +end +`, ":test\n:test\n[\"S\"]\n0\nbad-sym\nbad-obj\n") +} + +// TestActionMailerPerformAndRaiseFlags covers perform_deliveries= false (delivery +// skipped) and raise_delivery_errors gating a delivery-method error. +func TestActionMailerPerformAndRaiseFlags(t *testing.T) { + amCheck(t, amRendererStub+` +class Quiet < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "P"}); end +end +p Quiet.perform_deliveries +p Quiet.raise_delivery_errors +Quiet.perform_deliveries = false +Quiet.go.deliver_now +p ActionMailer::Base.deliveries.length + +boom = Object.new +def boom.deliver(m); raise "smtp down"; end +class Loud < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "R"}); end +end +Loud.delivery_method = boom +begin + Loud.go.deliver_now +rescue RuntimeError => e + puts "raised:#{e.message}" +end +Loud.raise_delivery_errors = false +p Loud.raise_delivery_errors +Loud.go.deliver_now +puts "swallowed" +`, "true\ntrue\n0\nraised:smtp down\nfalse\nswallowed\n") +} + +// TestActionMailerDeliveriesReset covers deliveries= replacing the sink (with an +// Array and with a non-Array reset) and the renderer/enqueuer nil readers. +func TestActionMailerDeliveriesReset(t *testing.T) { + amCheck(t, ` +p ActionMailer::Base.renderer +p ActionMailer::Base.enqueuer +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", body: "b"); end +end +M.go.deliver_now +p ActionMailer::Base.deliveries.length +ActionMailer::Base.deliveries = [] +p ActionMailer::Base.deliveries.length +M.go.deliver_now +p ActionMailer::Base.deliveries.length +ActionMailer::Base.deliveries = nil +p ActionMailer::Base.deliveries.length +`, "nil\nnil\n1\n0\n1\n0\n") +} + +// TestActionMailerErrors covers the composition error paths: an action that +// raises, an action that never calls mail, a renderer that raises, and calling +// mail outside a mailer action. +func TestActionMailerErrors(t *testing.T) { + amCheck(t, ` +class Boom < ActionMailer::Base + def go(x); raise ArgumentError, "no #{x}"; end + def empty; @x = 1; end +end +begin + Boom.go("way").deliver_now +rescue ArgumentError => e + puts "action:#{e.message}" +end +begin + Boom.empty.message +rescue => e + puts "nomail" +end +begin + Boom.new.send(:mail, to: "a@x.com") +rescue RuntimeError + puts "outside" +end + +bad = Object.new +def bad.render(opts); raise "tmpl broke"; end +ActionMailer::Base.renderer = bad +class R < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {}); end +end +begin + R.go.deliver_now +rescue RuntimeError => e + puts "render:#{e.message}" +end +`, "action:no way\nnomail\noutside\nrender:tmpl broke\n") +} + +// TestActionMailerInheritance covers a subclass inheriting the parent's default +// and a parent-defined action. +func TestActionMailerInheritance(t *testing.T) { + amCheck(t, amRendererStub+` +class AppMailer < ActionMailer::Base + default from: "app@x.com" + def base_action; mail(to: "a@x.com", subject: "Base", locals: {name: "N"}); end +end +class ChildMailer < AppMailer + def child_action; mail(to: "b@x.com", subject: "Child", locals: {name: "C"}); end +end +m1 = ChildMailer.child_action.deliver_now +p m1.from +p m1.subject +m2 = ChildMailer.base_action.deliver_now +p m2.subject +`, "\"app@x.com\"\n\"Child\"\n\"Base\"\n") +} + +// TestActionMailerValueMethods covers the ToS/Inspect/Truthy of the delivery and +// attachments Ruby value wrappers. +func TestActionMailerValueMethods(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go + a = attachments + puts a.inspect + puts a.to_s + puts(a ? "at" : "af") + mail(to: "a@x.com", subject: "S", body: "b") + end +end +md = M.go +p md +puts md.to_s +puts(md ? "t" : "f") +`, "#\n#\nat\n#\n#\nt\n") +} + +// TestActionMailerRedefine covers the method_added hook seeing a redefined action +// name (the class method is installed once). +func TestActionMailerRedefine(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "One", body: "b"); end + def go; mail(to: "a@x.com", subject: "Two", body: "b"); end +end +p M.go.deliver_now.subject +`, "\"Two\"\n") +} + +// TestActionMailerZeroArgHooks covers registering a hook with no argument (the +// amArg empty path) without delivering. +func TestActionMailerZeroArgHooks(t *testing.T) { + amCheck(t, ` +p ActionMailer::Base.send(:register_interceptor) +`, "nil\n") +} + +// TestActionMailerNoRendererEmptyBody covers the RenderBody seam with no renderer +// configured and no explicit body: both formats skip, yielding an empty body. +func TestActionMailerNoRendererEmptyBody(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S"); end +end +e = M.go.deliver_now.encoded +puts e.include?("Subject: S") +puts e.include?("multipart") == false +`, "true\ntrue\n") +} + +// TestActionMailerTwoCustomHeaders covers the second-custom-header branch of the +// mail(...) option parser (Headers map already allocated). +func TestActionMailerTwoCustomHeaders(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", body: "b", "X-A" => "1", "X-B" => "2"); end +end +e = M.go.deliver_now.encoded +puts e.include?("X-A: 1") +puts e.include?("X-B: 2") +`, "true\ntrue\n") +} + +// TestActionMailerOptionEdges covers non-Hash locals, a String attachment value +// coerced from a non-String, the attachments hash form with string keys, the +// attachments[]=/[] argument-count raises, mail() with no options and a +// non-Hash headers argument. +func TestActionMailerOptionEdges(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go + attachments["n.bin"] = 12345 + attachments["s.dat"] = { "content" => "x", "content_type" => "application/x-str" } + attachments["empty.dat"] = { "note" => "no content here" } + begin; attachments.send(:[]=, "only"); rescue ArgumentError; puts "set-args"; end + begin; attachments.send(:[]); rescue ArgumentError; puts "get-args"; end + mail(to: "a@x.com", subject: "S", body: "b", locals: "not-a-hash") + end +end +e = M.go.deliver_now.encoded +puts e.include?("application/x-str") +puts e.include?("n.bin") + +class N < ActionMailer::Base + def go + headers("just-a-string") + mail + end +end +N.go.deliver_now +puts "ok" +`, "set-args\nget-args\ntrue\ntrue\nok\n") +} + +// TestActionMailerDeliverLaterErrors covers deliver_later surfacing a delivery +// error inline and through an enqueuer that runs the deferred delivery. +func TestActionMailerDeliverLaterErrors(t *testing.T) { + amCheck(t, ` +boom = Object.new +def boom.deliver(m); raise "down"; end +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", body: "b"); end +end +M.delivery_method = boom +begin + M.go.deliver_later +rescue RuntimeError => e + puts "inline:#{e.message}" +end + +runner = Object.new +def runner.enqueue; yield; end +ActionMailer::Base.enqueuer = runner +begin + M.go.deliver_later +rescue RuntimeError => e + puts "enq:#{e.message}" +end +`, "inline:down\nenq:down\n") +} + +// TestActionMailerReaderBranches covers the renderer reader when set, the :test +// delivery method reaching the composer, and the perform_deliveries reader after +// it is declared. +func TestActionMailerReaderBranches(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "Z"}); end +end +puts ActionMailer::Base.renderer.nil? +M.delivery_method = :test +M.go.deliver_now +p ActionMailer::Base.deliveries.length +M.perform_deliveries = false +p M.perform_deliveries +`, "false\n1\nfalse\n") +} diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6721fcd8..c52b7abf 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -469,6 +469,7 @@ func (vm *VM) bootstrap() { vm.registerPublicSuffix() // PublicSuffix module (go-ruby-public-suffix backend); needs StandardError for PublicSuffix::Error tree vm.registerMIMETypes() // MIME::Types module (go-ruby-mime-types backend) vm.registerMail() // Mail module (go-ruby-mail backend) + vm.registerActionMailer() // ActionMailer::Base subclass DSL (default/delivery_method/register_interceptor/register_observer) + MessageDelivery proxy (deliver_now/deliver_later/message) + mail/attachments/headers (require "action_mailer"), backed by go-ruby-actionmailer; the mailer-action body, RenderBody (Action View), delivery method and EnqueueJob (Active Job) are Ruby-dispatch seams run INLINE under the GVL; needs the Mail message surface, so registered after registerMail vm.registerFaker() // Faker module (go-ruby-faker backend); needs Random for the seed contract vm.registerCommonmark() // Commonmark.render_html / String#to_html (require "commonmark"), backed by go-ruby-commonmark vm.registerMustache() // Mustache.render + Mustache view class (require "mustache"); needs StandardError for Mustache::Error diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 297665ed..a1c4ee72 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1132,6 +1132,10 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["Mail::Body"].(*RClass) case *MailField: return vm.consts["Mail::Field"].(*RClass) + case *ActionMailerDelivery: + return x.cls + case *ActionMailerAttachments: + return x.cls case *FileStat: return vm.cFileStat case *BigDecimal: diff --git a/internal/vm/require.go b/internal/vm/require.go index 1ce3dd7d..7cae302e 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -83,6 +83,7 @@ var providedFeatures = map[string]bool{ "hanami": true, "hanami/router": true, "hanami/action": true, "warden": true, "omniauth": true, "public_suffix": true, "mime/types": true, "mail": true, "faker": true, + "action_mailer": true, "actionmailer": true, "rqrcode": true, "dotenv": true, "hcl2": true, "kramdown": true, "liquid": true, "rouge": true, "slim": true, "haml": true, "dry/types": true, "dry-types": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 94d4017c..9681e17a 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -19,6 +19,7 @@ import ( "strings" "sync" + actionmailer "github.com/go-ruby-actionmailer/actionmailer" activejob "github.com/go-ruby-activejob/activejob" activestorage "github.com/go-ruby-activestorage/activestorage" inflector "github.com/go-ruby-activesupport/activesupport/inflector" @@ -279,6 +280,14 @@ type VM struct { ajStack []*RObject // ActiveJob: instance stack the inline #perform seam reads ajLastResult object.Value // ActiveJob: last #perform return value (perform_now's result) ajArgs *activejob.Arguments // ActiveJob: module-level ActiveJob::Arguments serializer (GlobalID seam wired in registerActiveJob) + cActionMailerBase *RClass // ActionMailer::Base, the superclass every mailer subclasses (require "action_mailer"), backed by go-ruby-actionmailer + cActionMailerDelivery *RClass // ActionMailer::MessageDelivery, the lazy proxy a mailer action returns + cActionMailerAttachments *RClass // the internal `attachments` proxy class (Mail::AttachmentsList analogue) + amDefs map[*RClass]*amDef // ActionMailer: per-mailer-class default/delivery/hook declarations, merged down the chain at delivery time + amDeliveries *object.Array // ActionMailer: the shared ActionMailer::Base.deliveries Array the :test delivery method appends to + amMailerOf map[*RObject]*actionmailer.Mailer // ActionMailer: mailer instance -> library *Mailer of its running action (the mail/attachments/headers seam) + amRenderer object.Value // ActionMailer: the Ruby renderer the RenderBody seam sends #render to (Action View wiring / test stub) + amEnqueuer object.Value // ActionMailer: the Ruby enqueuer the deliver_later seam sends #enqueue to (Active Job wiring / test stub) asConfig *activestorage.Config // ActiveStorage process config (require "active_storage"); nil until first use, then a deterministic in-process config (MemStore + DiskService temp dir) 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) From dc55e9eab6999ef320ab707e53461ab40a511096 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 23:16:38 +0200 Subject: [PATCH 3/5] Bind ActionView view layer into rbgo (require "action_view") Bind github.com/go-ruby-actionview/actionview as a native module so `require "action_view"` works with no .rb file. ActionView::Base is the view context exposing the pure tag/url/form/text/number helpers (delegating to the library package functions) alongside the stateful helpers (button_to, form_tag, form_with, current_page?, render) that build a fresh library Context per call from the instance configuration. Surface bound: - ActiveSupport::SafeBuffer (+ ActionView::OutputBuffer alias) with escape-on-concat << / concat / safe_concat / + and String#html_safe; - tag helpers content_tag/tag/token_list/class_names/cdata_section; - url helpers link_to/mail_to/button_to/current_page?/url_for; - form_tag-family inputs, select_tag, submit/button, options_for_select, form_tag, and form_with yielding an ActionView::Helpers::FormBuilder; - text helpers truncate/pluralize/simple_format/word_wrap/highlight/ excerpt and the eight NumberHelper formatters; - ActionView::PartialIteration for collection partials. Seams: URLFor (routes) wires to a Ruby url_for= callable, else the identity/path default; RenderTemplate wires to a Ruby render_template= callable, else an inline-ERB default that evaluates the template through the already-bound go-ruby-erb compiler so <%= %> runs Ruby against the view context (binding-scoped locals + @ivars via the ivarTable seam). #render stays an ordinary dispatchable method so a later actionpack/ actionmailer binding renders a view by sending :render to a Base. 100% coverage of the new files; full internal/vm + internal/object suites green; gofmt/vet clean; builds on all six 64-bit arches and under the closed-world build tag. Co-Authored-By: Claude Opus 4.8 --- go.mod | 1 + go.sum | 2 + internal/vm/actionview.go | 572 ++++++++++++++++++++++++ internal/vm/actionview_bind.go | 338 ++++++++++++++ internal/vm/actionview_form.go | 137 ++++++ internal/vm/actionview_internal_test.go | 382 ++++++++++++++++ internal/vm/actionview_render_closed.go | 21 + internal/vm/actionview_render_open.go | 77 ++++ internal/vm/arith.go | 6 + internal/vm/object_model.go | 13 + internal/vm/require.go | 1 + internal/vm/vm.go | 1 + 12 files changed, 1551 insertions(+) create mode 100644 internal/vm/actionview.go create mode 100644 internal/vm/actionview_bind.go create mode 100644 internal/vm/actionview_form.go create mode 100644 internal/vm/actionview_internal_test.go create mode 100644 internal/vm/actionview_render_closed.go create mode 100644 internal/vm/actionview_render_open.go diff --git a/go.mod b/go.mod index cfca96e2..e72d3e1b 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-ruby-abbrev/abbrev v0.0.0-20260629150957-97117892cd38 github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 + github.com/go-ruby-actionview/actionview v0.0.0-20260706115423-0e293eed051a github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a github.com/go-ruby-activerecord/activerecord v0.0.0-20260702222646-da57bd9e07f6 diff --git a/go.sum b/go.sum index fdcd8f98..23fdbffa 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe h1:oYFDl+kcAkTOX github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe/go.mod h1:Bh8Vr5Vy9UO7JqU5f6myUOWl0jjnnvtlGdIVTBbdLRw= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 h1:VAFOmKa1u2HKA2LFQhelusPVIAtgp+wAOkXl8IjURbQ= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783/go.mod h1:5DS3icNZKImRwvDP7Kf9IHv+9d6EYtcvRpBALgh/s/Y= +github.com/go-ruby-actionview/actionview v0.0.0-20260706115423-0e293eed051a h1:NhatSlzDFDTNAIYng5hhUltGPS0O5xiPr5efQ+RsL9k= +github.com/go-ruby-actionview/actionview v0.0.0-20260706115423-0e293eed051a/go.mod h1:n9SEnqxWmYIXKgtrIZEiRvGui0lWdCd2GJELRpa44ls= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 h1:vRJAWboG75rQWpfTiNQ8XkFRJ1Am9RNKdRKSlTLQBiA= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4/go.mod h1:HBZ9KJKoGTzo4h6/5+gaYyzMYWQtucT660mE68zHZGw= github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a h1:d8kCh4DxIJEQJhd/ZD6niTnxEzWgnZ5NlOmxfdBRDZQ= diff --git a/internal/vm/actionview.go b/internal/vm/actionview.go new file mode 100644 index 00000000..c78c49de --- /dev/null +++ b/internal/vm/actionview.go @@ -0,0 +1,572 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + + actionview "github.com/go-ruby-actionview/actionview" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// avContains reports whether haystack contains needle, backing SafeBuffer#include?. +func avContains(haystack, needle string) bool { return strings.Contains(haystack, needle) } + +// registerActionView installs the native backbone of the ActionView view layer +// (require "action_view"): the ActionView::Base view context with the tag / url / +// form / text / number helpers and the render pipeline, the ActionView::Base +// FormBuilder yielded by form_with, ActionView::PartialIteration, and the +// html-safe ActiveSupport::SafeBuffer (plus String#html_safe). Every observable +// byte — tag_options rendering, form name/id conventions, currency/number +// formatting, SafeBuffer escaping — comes from the pure-Go +// github.com/go-ruby-actionview/actionview library; this is the wiring that maps +// Ruby arguments to it and back (see actionview_bind.go). It is registered after +// registerERB/registerErubi (it reuses the ERB escaping/compiler surface) and +// after registerActiveSupport (SafeBuffer nests under ActiveSupport, and the +// helpers reuse the inflector/core-ext through the library). +func (vm *VM) registerActionView() { + mod := newClass("ActionView", nil) + mod.isModule = true + vm.consts["ActionView"] = mod + + vm.registerSafeBuffer() + vm.registerActionViewPartialIteration(mod) + vm.registerActionViewBase(mod) + vm.registerActionViewFormBuilder(mod) +} + +// registerSafeBuffer installs ActiveSupport::SafeBuffer (aliased as +// ActionView::OutputBuffer): the html-safe string whose concat operators escape +// unsafe input on append. It also teaches String#html_safe / #html_safe? so a +// plain String can be marked safe (returning a SafeBuffer) or queried (always +// false for a bare String). +func (vm *VM) registerSafeBuffer() { + as, _ := vm.consts["ActiveSupport"].(*RClass) + cSB := newClass("ActiveSupport::SafeBuffer", vm.cObject) + vm.consts["ActiveSupport::SafeBuffer"] = cSB + if as != nil { + as.consts["SafeBuffer"] = cSB + } + if av, ok := vm.consts["ActionView"].(*RClass); ok { + av.consts["OutputBuffer"] = cSB + } + + cSB.smethods["new"] = &Method{name: "new", owner: cSB, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + s := "" + if len(args) > 0 { + s = avToS(args[0]) + } + return vm.newSafeBuffer(actionview.Raw(s)) + }} + + cSB.define("to_s", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self.(*SafeBufferVal).buf.String()) + }) + cSB.define("to_str", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self.(*SafeBufferVal).buf.String()) + }) + cSB.define("html_safe?", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(true) + }) + cSB.define("html_safe", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return self + }) + cSB.define("length", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self.(*SafeBufferVal).buf.Len())) + }) + cSB.define("empty?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self.(*SafeBufferVal).buf.Len() == 0) + }) + concat := func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*SafeBufferVal) + if sb, ok := avArg(args, 0).(*SafeBufferVal); ok { + b.buf.AppendSafe(*sb.buf) + } else { + b.buf.Concat(avToS(avArg(args, 0))) + } + return b + } + cSB.define("<<", concat) + cSB.define("concat", concat) + cSB.define("safe_concat", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*SafeBufferVal) + b.buf.SafeConcat(avToS(avArg(args, 0))) + return b + }) + // + returns a fresh SafeBuffer of the concatenation, escaping the right operand + // unless it is itself html-safe (ActiveSupport::SafeBuffer#+). This is what lets + // a view build markup with `f.label + f.text_field + …` without XSS holes. + cSB.define("+", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + out := vm.newSafeBuffer(actionview.Raw(self.(*SafeBufferVal).buf.String())) + if sb, ok := avArg(args, 0).(*SafeBufferVal); ok { + out.buf.AppendSafe(*sb.buf) + } else { + out.buf.Concat(avToS(avArg(args, 0))) + } + return out + }) + // include? answers a substring query against the raw markup, so a view/spec can + // assert on rendered output without an explicit #to_s (SafeBuffer is a String in + // Rails and responds to it). + cSB.define("include?", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(avContains(self.(*SafeBufferVal).buf.String(), avToS(avArg(args, 0)))) + }) + + vm.cString.define("html_safe", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.Raw(self.(*object.String).Str())) + }) + vm.cString.define("html_safe?", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(false) + }) +} + +// registerActionViewPartialIteration installs ActionView::PartialIteration, the +// per-element iteration state a collection partial sees as its _iteration +// local (index / size / first? / last?), backed by the library's PartialIteration. +func (vm *VM) registerActionViewPartialIteration(mod *RClass) { + cPI := newClass("ActionView::PartialIteration", vm.cObject) + vm.consts["ActionView::PartialIteration"] = cPI + mod.consts["PartialIteration"] = cPI + avPartialIterClass = cPI + + cPI.define("index", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self.(*AVPartialIter).p.Index)) + }) + cPI.define("size", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self.(*AVPartialIter).p.Size)) + }) + cPI.define("first?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self.(*AVPartialIter).p.First()) + }) + cPI.define("last?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self.(*AVPartialIter).p.Last()) + }) +} + +// registerActionViewBase installs ActionView::Base and every helper the view +// context exposes. The pure helpers delegate straight to the library package +// functions; the stateful helpers (button_to, form_tag, form_with, current_page?, +// render) build a fresh library Context from the instance configuration; and the +// configuration writers (protect_against_forgery=, authenticity_token=, the +// request/CSRF fields, and the url_for= / render_template= seam callables) let a +// host opt into routing, forgery protection and template resolution. +func (vm *VM) registerActionViewBase(mod *RClass) { + base := newClass("ActionView::Base", vm.cObject) + vm.consts["ActionView::Base"] = base + mod.consts["Base"] = base + + base.smethods["new"] = &Method{name: "new", owner: base, native: func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.newActionViewBase() + }} + + // --- output safety --------------------------------------------------------- + base.define("raw", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.Raw(avToS(avArg(args, 0)))) + }) + hEscape := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + v := avArg(args, 0) + if sb, ok := v.(*SafeBufferVal); ok { + return sb + } + return vm.newSafeBuffer(actionview.Raw(actionview.HTMLEscape(avToS(v)))) + } + base.define("html_escape", hEscape) + base.define("h", hEscape) + + // --- tag helpers ----------------------------------------------------------- + base.define("content_tag", func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + name := avToS(avArg(args, 0)) + if blk != nil { + content := avValue(vm.callBlock(blk, nil)) + return vm.newSafeBuffer(actionview.ContentTag(name, content, avOpts(avArg(args, 1)))) + } + return vm.newSafeBuffer(actionview.ContentTag(name, avValue(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.Tag(avToS(avArg(args, 0)), avValue(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + tokenList := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + vals := make([]any, len(args)) + for i, a := range args { + vals[i] = avValue(a) + } + return vm.newSafeBuffer(actionview.TokenList(vals...)) + } + base.define("token_list", tokenList) + base.define("class_names", tokenList) + base.define("cdata_section", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.CDATASection(avToS(avArg(args, 0)))) + }) + + // --- url helpers ----------------------------------------------------------- + base.define("link_to", func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + if blk != nil { + url := avToS(avArg(args, 0)) + content := avValue(vm.callBlock(blk, nil)) + return vm.newSafeBuffer(actionview.LinkTo(content, url, avOpts(avArg(args, 1)))) + } + return vm.newSafeBuffer(actionview.LinkTo(avValue(avArg(args, 0)), avToS(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("mail_to", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.MailTo(avToS(avArg(args, 0)), avValue(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("button_to", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*ActionViewBase) + opts := avOptHash(args, 2) + method := avHashStr(opts, "method", "post") + return vm.newSafeBuffer(b.context().ButtonTo(avValue(avArg(args, 0)), avToS(avArg(args, 1)), method, avOptsExcept(opts, "method"))) + }) + base.define("current_page?", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*ActionViewBase) + return object.Bool(b.context().CurrentPage(avArg(args, 0))) + }) + base.define("url_for", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*ActionViewBase) + return object.NewString(b.urlFor(avArg(args, 0))) + }) + + vm.registerActionViewFormTagHelpers(base) + vm.registerActionViewTextHelpers(base) + vm.registerActionViewNumberHelpers(base) + vm.registerActionViewConfig(base) + vm.registerActionViewRender(base) +} + +// registerActionViewFormTagHelpers installs the object-independent form_tag-family +// helpers (the *_tag inputs, select_tag, submit/button, options_for_select and +// form_tag itself) on ActionView::Base. +func (vm *VM) registerActionViewFormTagHelpers(base *RClass) { + base.define("text_field_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.TextFieldTag(avToS(avArg(args, 0)), avValue(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("password_field_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.PasswordFieldTag(avToS(avArg(args, 0)), avValue(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("hidden_field_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.HiddenFieldTag(avToS(avArg(args, 0)), avValue(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("text_area_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.TextAreaTag(avToS(avArg(args, 0)), avValue(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("check_box_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + checked := false + if v := avArg(args, 2); v != nil { + checked = v.Truthy() + } + return vm.newSafeBuffer(actionview.CheckBoxTag(avToS(avArg(args, 0)), avToS(avArg(args, 1)), checked, avOpts(avArg(args, 3)))) + }) + base.define("radio_button_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + checked := false + if v := avArg(args, 2); v != nil { + checked = v.Truthy() + } + return vm.newSafeBuffer(actionview.RadioButtonTag(avToS(avArg(args, 0)), avToS(avArg(args, 1)), checked, avOpts(avArg(args, 3)))) + }) + base.define("select_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + opts := avOptHash(args, 2) + includeBlank := avHashBool(opts, "include_blank", false) + return vm.newSafeBuffer(actionview.SelectTag(avToS(avArg(args, 0)), avToSafeBuffer(avArg(args, 1)), includeBlank, avOptsExcept(opts, "include_blank"))) + }) + base.define("label_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.LabelTag(avToS(avArg(args, 0)), avToS(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + base.define("submit_tag", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.SubmitTag(avToS(avArg(args, 0)), avOpts(avArg(args, 1)))) + }) + base.define("button_tag", func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + if blk != nil { + return vm.newSafeBuffer(actionview.ButtonTag(avValue(vm.callBlock(blk, nil)), avOpts(avArg(args, 0)))) + } + return vm.newSafeBuffer(actionview.ButtonTag(avValue(avArg(args, 0)), avOpts(avArg(args, 1)))) + }) + base.define("options_for_select", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.OptionsForSelect(avChoices(avArg(args, 0)), avToS(avArg(args, 1)))) + }) + base.define("form_tag", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + b := self.(*ActionViewBase) + opts := avOptHash(args, 1) + method := avHashStr(opts, "method", "post") + url := avToS(avArg(args, 0)) + attrs := avOptsExcept(opts, "method") + if blk != nil { + body := avToSafeBuffer(vm.callBlock(blk, nil)) + return vm.newSafeBuffer(b.context().FormTag(url, method, attrs, body)) + } + return vm.newSafeBuffer(b.context().FormTagOpen(url, method, attrs)) + }) +} + +// registerActionViewTextHelpers installs the TextHelper family (truncate, +// pluralize, simple_format, word_wrap, highlight, excerpt) on ActionView::Base. +func (vm *VM) registerActionViewTextHelpers(base *RClass) { + base.define("truncate", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + o := avOptHash(args, 1) + return vm.newSafeBuffer(actionview.Truncate(avToS(avArg(args, 0)), + avHashInt(o, "length", 30), avHashStr(o, "omission", "..."), + avHashStr(o, "separator", ""), avHashBool(o, "escape", true))) + }) + base.define("pluralize", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(actionview.Pluralize(avValue(avArg(args, 0)), avToS(avArg(args, 1)), avToS(avArg(args, 2)))) + }) + base.define("simple_format", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSafeBuffer(actionview.SimpleFormat(avToS(avArg(args, 0)), + avOpts(avArg(args, 1)), avHashStr(avOptHash(args, 2), "wrapper_tag", ""), nil)) + }) + base.define("word_wrap", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + o := avOptHash(args, 1) + return object.NewString(actionview.WordWrap(avToS(avArg(args, 0)), + avHashInt(o, "line_width", 80), avHashStr(o, "break_sequence", "\n"))) + }) + base.define("highlight", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + o := avOptHash(args, 2) + return vm.newSafeBuffer(actionview.Highlight(avToS(avArg(args, 0)), + avPhrases(avArg(args, 1)), avHashStr(o, "highlighter", ""), nil)) + }) + base.define("excerpt", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + o := avOptHash(args, 2) + s, ok := actionview.Excerpt(avToS(avArg(args, 0)), avToS(avArg(args, 1)), + avHashInt(o, "radius", 100), avHashStr(o, "omission", "..."), avHashStr(o, "separator", "")) + if !ok { + return object.NilV + } + return object.NewString(s) + }) +} + +// registerActionViewNumberHelpers installs the NumberHelper family on +// ActionView::Base, each returning the library's formatted (plain) String. +func (vm *VM) registerActionViewNumberHelpers(base *RClass) { + num := func(fn func(any, ...actionview.Option) string) NativeFn { + return func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(fn(avValue(avArg(args, 0)), avNumberOpts(avOptHash(args, 1))...)) + } + } + base.define("number_to_currency", num(actionview.NumberToCurrency)) + base.define("number_with_delimiter", num(actionview.NumberWithDelimiter)) + base.define("number_to_delimited", num(actionview.NumberToDelimited)) + base.define("number_with_precision", num(actionview.NumberWithPrecision)) + base.define("number_to_rounded", num(actionview.NumberToRounded)) + base.define("number_to_percentage", num(actionview.NumberToPercentage)) + base.define("number_to_human_size", num(actionview.NumberToHumanSize)) + base.define("number_to_human", num(actionview.NumberToHuman)) +} + +// registerActionViewConfig installs the view-context configuration writers: the +// request/CSRF state and the two host seam callables (url_for= for routing, +// render_template= for the template resolver a later actionpack binding wires). +func (vm *VM) registerActionViewConfig(base *RClass) { + base.define("authenticity_token=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).authenticityToken = avToS(avArg(args, 0)) + return avArg(args, 0) + }) + base.define("protect_against_forgery=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).protectAgainstForgery = avArg(args, 0).Truthy() + return avArg(args, 0) + }) + base.define("suppress_utf8_enforcer=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).suppressUTF8 = avArg(args, 0).Truthy() + return avArg(args, 0) + }) + base.define("request_method=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).requestMethod = avToS(avArg(args, 0)) + return avArg(args, 0) + }) + base.define("request_path=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).requestPath = avToS(avArg(args, 0)) + return avArg(args, 0) + }) + base.define("request_fullpath=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).requestFullpath = avToS(avArg(args, 0)) + return avArg(args, 0) + }) + base.define("url_for=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).urlForProc = avProc(avArg(args, 0)) + return avArg(args, 0) + }) + base.define("render_template=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*ActionViewBase).renderTemplateProc = avProc(avArg(args, 0)) + return avArg(args, 0) + }) +} + +// registerActionViewRender installs ActionView::Base#render — the dispatchable +// render entry point. It maps a render options Hash (inline / template / partial / +// collection / locals / as / spacer_template) to the library's RenderOptions and +// defers template evaluation to the context's RenderTemplate seam. A later +// actionpack/actionmailer binding renders a view by sending :render to a Base with +// its own render_template callable wired, so the whole pipeline funnels through +// this one method. +func (vm *VM) registerActionViewRender(base *RClass) { + base.define("render", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*ActionViewBase) + h, ok := avArg(args, 0).(*object.Hash) + if !ok { + raise("ArgumentError", "render expects an options Hash") + } + ro := actionview.RenderOptions{ + Inline: avHashStr(h, "inline", ""), + Template: avHashStr(h, "template", ""), + Partial: avHashStr(h, "partial", ""), + As: avHashStr(h, "as", ""), + Spacer: avHashStr(h, "spacer_template", ""), + Locals: avLocalsMap(h), + } + if v, ok := avHashVal(h, "collection"); ok { + if arr, ok := v.(*object.Array); ok { + ro.Collection = avCollection(arr) + } + } + out, err := b.context().Render(ro) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return vm.newSafeBuffer(out) + }) +} + +// avLocalsMap reads the render :locals option into a Go map keyed by the local +// name, holding each value as its original Ruby object.Value so the seam can bind +// it verbatim. A missing / non-Hash :locals yields no locals. +func avLocalsMap(h *object.Hash) map[string]any { + lv, ok := avHashVal(h, "locals") + if !ok { + return nil + } + lh, ok := lv.(*object.Hash) + if !ok { + return nil + } + out := make(map[string]any, len(lh.Keys)) + for _, k := range lh.Keys { + val, _ := lh.Get(k) + out[avKey(k)] = val + } + return out +} + +// avCollection maps a render :collection Array to a []any of the elements' Ruby +// values (kept as object.Value so the per-element local binds the real object). +func avCollection(arr *object.Array) []any { + out := make([]any, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = e + } + return out +} + +// avOptsExcept converts an options Hash to Attrs while dropping the named keys +// (e.g. the :method / :include_blank keys a helper consumes itself rather than +// emitting as an HTML attribute). +func avOptsExcept(h *object.Hash, drop ...string) actionview.Attrs { + if h == nil { + return nil + } + skip := make(map[string]bool, len(drop)) + for _, d := range drop { + skip[d] = true + } + out := make(actionview.Attrs, 0, len(h.Keys)) + for _, k := range h.Keys { + name := avKey(k) + if skip[name] { + continue + } + val, _ := h.Get(k) + out = append(out, actionview.Attr{Key: name, Val: avValue(val)}) + } + return out +} + +// avToSafeBuffer coerces an argument to a library SafeBuffer of already-safe +// markup: a Ruby SafeBuffer contributes its markup verbatim, any other value its +// to_s (used for a form/select body and the render result, which are assembled +// from helper output). +func avToSafeBuffer(v object.Value) actionview.SafeBuffer { + if sb, ok := v.(*SafeBufferVal); ok { + return *sb.buf + } + return actionview.Raw(avToS(v)) +} + +// avChoices maps an options_for_select container to the library's []Choice: a +// two-element Array element becomes Choice{text, value}, and any other element is +// used as both text and value. +func avChoices(v object.Value) []actionview.Choice { + arr, ok := v.(*object.Array) + if !ok { + return nil + } + out := make([]actionview.Choice, len(arr.Elems)) + for i, e := range arr.Elems { + if pair, ok := e.(*object.Array); ok && len(pair.Elems) == 2 { + out[i] = actionview.Choice{Text: avToS(pair.Elems[0]), Value: avToS(pair.Elems[1])} + continue + } + s := avToS(e) + out[i] = actionview.Choice{Text: s, Value: s} + } + return out +} + +// avPhrases maps the highlight phrases argument (a String or an Array of Strings) +// to a []string. +func avPhrases(v object.Value) []string { + if arr, ok := v.(*object.Array); ok { + out := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = avToS(e) + } + return out + } + return []string{avToS(v)} +} + +// avNumberOpts maps a number-helper options Hash to the library's functional +// Options: precision / significant / separator / delimiter / unit / format / +// negative_format / strip_insignificant_zeros. An absent key leaves the helper's +// own default in place. +func avNumberOpts(h *object.Hash) []actionview.Option { + if h == nil { + return nil + } + var o []actionview.Option + if v, ok := avHashVal(h, "precision"); ok { + if i, ok := v.(object.Integer); ok { + o = append(o, actionview.Precision(int(i))) + } + } + if v, ok := avHashVal(h, "significant"); ok { + o = append(o, actionview.Significant(v.Truthy())) + } + if v, ok := avHashVal(h, "separator"); ok { + o = append(o, actionview.Separator(avToS(v))) + } + if v, ok := avHashVal(h, "delimiter"); ok { + o = append(o, actionview.Delimiter(avToS(v))) + } + if v, ok := avHashVal(h, "unit"); ok { + o = append(o, actionview.Unit(avToS(v))) + } + if v, ok := avHashVal(h, "format"); ok { + o = append(o, actionview.Format(avToS(v))) + } + if v, ok := avHashVal(h, "negative_format"); ok { + o = append(o, actionview.NegativeFormat(avToS(v))) + } + if v, ok := avHashVal(h, "strip_insignificant_zeros"); ok { + o = append(o, actionview.StripInsignificantZeros(v.Truthy())) + } + return o +} + +// avProc returns the Proc value of v (a block/lambda passed to a seam writer), or +// nil when v is nil / not a Proc (clearing the seam). +func avProc(v object.Value) *Proc { + if p, ok := v.(*Proc); ok { + return p + } + return nil +} diff --git a/internal/vm/actionview_bind.go b/internal/vm/actionview_bind.go new file mode 100644 index 00000000..1cc8b011 --- /dev/null +++ b/internal/vm/actionview_bind.go @@ -0,0 +1,338 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "fmt" + + actionview "github.com/go-ruby-actionview/actionview" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the thin binding between rbgo's Ruby object graph and the +// interpreter-independent github.com/go-ruby-actionview/actionview library — the +// pure-Go, MRI-faithful reimplementation of ActionView's html-safe buffer and the +// tag / url / form / text / number view helpers. The observable output +// (tag_options rendering, form name/id conventions, currency/number formatting, +// link_to / content_tag markup, SafeBuffer escaping) is produced by that library; +// rbgo only maps Ruby arguments to the library's Go types and wraps its results. +// +// ActionView::Base is the view context: the stateful helpers (form_tag, +// form_with, button_to, current_page?, render) hang off it, while the pure +// helpers are library package functions the same class exposes. Its two seams — +// URLFor (the routes seam) and RenderTemplate (the template-evaluation seam) — +// are wired here to Ruby callables when the host supplies them, and otherwise to +// sensible defaults (url_for is the identity/path formatter, render evaluates an +// inline ERB template through the already-bound go-ruby-erb compiler so <%= %> +// runs Ruby). Because #render is an ordinary dispatchable method on +// ActionView::Base, a later actionpack/actionmailer binding renders a view by +// simply sending :render to a Base instance. + +// SafeBufferVal is a Ruby ActiveSupport::SafeBuffer / ActionView::OutputBuffer: +// an html-safe string. It wraps the library's *actionview.SafeBuffer so the +// mutating concat operators (<< / concat / safe_concat) update in place while the +// escape-on-append policy stays the library's. The cls field lets classOf report +// the SafeBuffer class. +type SafeBufferVal struct { + cls *RClass + buf *actionview.SafeBuffer +} + +// ToS returns the buffer's raw markup (ActiveSupport::SafeBuffer#to_s). +func (b *SafeBufferVal) ToS() string { return b.buf.String() } + +// Inspect renders the buffer's contents quoted, matching how a String inspects. +func (b *SafeBufferVal) Inspect() string { return fmt.Sprintf("%q", b.buf.String()) } + +// Truthy reports true: a SafeBuffer, like any String, is always truthy. +func (b *SafeBufferVal) Truthy() bool { return true } + +// newSafeBuffer wraps a library SafeBuffer value as a Ruby SafeBufferVal of the +// bound class, copying it to a fresh pointer so the mutators operate on the +// wrapper's own storage. +func (vm *VM) newSafeBuffer(sb actionview.SafeBuffer) *SafeBufferVal { + cp := sb + return &SafeBufferVal{cls: vm.consts["ActiveSupport::SafeBuffer"].(*RClass), buf: &cp} +} + +// ActionViewBase is a Ruby ActionView::Base instance: the view context carrying +// the request/routing/CSRF configuration and the two host seams (url_for and +// render_template). Its zero configuration mirrors a bare helper include with +// forgery protection off. A fresh actionview.Context is built per stateful call +// from these fields, so mutating the configuration between calls is honoured. +type ActionViewBase struct { + cls *RClass + vm *VM + ivars map[string]object.Value + + authenticityToken string + protectAgainstForgery bool + suppressUTF8 bool + requestMethod string + requestPath string + requestFullpath string + + // urlForProc, when set, is the Ruby routes callable url_for resolves through; + // renderTemplateProc, when set, is the Ruby template resolver #render evaluates + // identifiers with. Both nil selects the built-in defaults. + urlForProc *Proc + renderTemplateProc *Proc +} + +// ToS renders the default Object#to_s form; a view context has no meaningful +// string value of its own. +func (b *ActionViewBase) ToS() string { return "#" } + +// Inspect matches ToS. +func (b *ActionViewBase) Inspect() string { return b.ToS() } + +// Truthy reports true, as every ordinary object does. +func (b *ActionViewBase) Truthy() bool { return true } + +// newActionViewBase constructs a fresh ActionView::Base bound to vm. A later +// actionpack/actionmailer binding uses this to obtain a render context and then +// dispatches :render to it. +func (vm *VM) newActionViewBase() *ActionViewBase { + return &ActionViewBase{cls: vm.consts["ActionView::Base"].(*RClass), vm: vm, ivars: map[string]object.Value{}} +} + +// context builds the library Context from the current configuration, wiring the +// URLFor and RenderTemplate seams to this instance's methods (which dispatch to +// the Ruby callables or the defaults). +func (b *ActionViewBase) context() *actionview.Context { + return &actionview.Context{ + URLFor: b.urlFor, + AuthenticityToken: b.authenticityToken, + ProtectAgainstForgery: b.protectAgainstForgery, + SuppressUTF8Enforcer: b.suppressUTF8, + RequestMethod: b.requestMethod, + RequestPath: b.requestPath, + RequestFullpath: b.requestFullpath, + RenderTemplate: b.renderTemplate, + } +} + +// urlFor is the routes seam: it resolves a routing argument to a URL string. When +// a Ruby url_for callable is configured it is invoked with the argument (a Ruby +// value); otherwise the argument is used as-is (a String passes through, anything +// else is stringified), matching the library's identity default. +func (b *ActionViewBase) urlFor(o any) string { + // The library calls this seam with either a Go string (the form_with url) or a + // Ruby value (the current_page? / url_for argument); those are the only two + // producers, so a non-string is asserted to object.Value. + var ov object.Value + if s, ok := o.(string); ok { + ov = object.NewString(s) + } else { + ov = o.(object.Value) + } + if b.urlForProc != nil { + return avToS(b.vm.callBlock(b.urlForProc, []object.Value{ov})) + } + return avToS(ov) +} + +// renderTemplate is the template-evaluation seam #render defers to. When a Ruby +// render_template callable is configured it is invoked with the resolved +// identifier and the locals (as a Ruby Hash) and its result is the rendered +// markup — this is the hook a later actionpack binding wires to its own template +// resolver. Otherwise the identifier is treated as an inline ERB template source +// and evaluated (through the bound go-ruby-erb compiler) with the locals bound as +// local variables, so <%= %> runs Ruby against this view context. +func (b *ActionViewBase) renderTemplate(identifier string, locals map[string]any) (string, error) { + if b.renderTemplateProc != nil { + h := object.NewHash() + for k, v := range locals { + h.Set(object.NewString(k), avLocalToRuby(v)) + } + r := b.vm.callBlock(b.renderTemplateProc, []object.Value{object.NewString(identifier), h}) + return avToS(r), nil + } + return b.vm.actionViewInlineRender(b, identifier, locals), nil +} + +// AVPartialIter is a Ruby ActionView::PartialIteration: the per-element iteration +// state a collection partial sees as its _iteration local. It wraps the +// library's PartialIteration so #index / #size / #first? / #last? report the +// library's values. +type AVPartialIter struct { + cls *RClass + p actionview.PartialIteration +} + +// ToS renders the default object form; the iteration state has no string value. +func (p *AVPartialIter) ToS() string { return "#" } + +// Inspect matches ToS. +func (p *AVPartialIter) Inspect() string { return p.ToS() } + +// Truthy reports true. +func (p *AVPartialIter) Truthy() bool { return true } + +// avLocalToRuby maps a render local the library hands to the RenderTemplate seam +// back to a Ruby value: user locals are stored as their original object.Value and +// pass through; the library-injected _counter is a Go int; and the +// _iteration is a library PartialIteration wrapped as an AVPartialIter. +func avLocalToRuby(v any) object.Value { + // The three producers are: a user local (its original object.Value), the + // library-injected _counter (a Go int) and the _iteration (a + // library PartialIteration). A non-int, non-PartialIteration value is therefore + // always the object.Value of a user local. + if i, ok := v.(int); ok { + return object.IntValue(int64(i)) + } + if p, ok := v.(actionview.PartialIteration); ok { + return &AVPartialIter{cls: avPartialIterClass, p: p} + } + return v.(object.Value) +} + +// avPartialIterClass is the ActionView::PartialIteration class, captured at +// registration so avLocalToRuby (called from the library seam, without a vm in +// scope) can stamp the wrapper. +var avPartialIterClass *RClass + +// avValue maps a Ruby argument to the Go value the library helpers consume, +// preserving the type distinctions tag_options / safeString switch on: nil, bool, +// Integer (as Go int), Float, String, Symbol (as its name), a SafeBuffer (as the +// library's html-safe SafeBuffer so it is not re-escaped), an Array (as []any for +// class/token joining) and a Hash (as map[string]any for data/aria expansion and +// class hashes). Anything else falls back to its Ruby to_s. +func avValue(v object.Value) any { + switch x := v.(type) { + case nil, object.Nil: + return nil + case object.Bool: + return bool(x) + case object.Integer: + return int(x) + case object.Float: + return float64(x) + case *object.String: + return x.Str() + case object.Symbol: + return string(x) + case *SafeBufferVal: + return *x.buf + case *object.Array: + out := make([]any, len(x.Elems)) + for i, e := range x.Elems { + out[i] = avValue(e) + } + return out + case *object.Hash: + m := make(map[string]any, len(x.Keys)) + for _, k := range x.Keys { + val, _ := x.Get(k) + m[avKey(k)] = avValue(val) + } + return m + } + return v.ToS() +} + +// avKey renders a Ruby Hash key (a Symbol or String, the attribute-name shapes) +// as its bare name; any other key falls back to its to_s. +func avKey(k object.Value) string { + switch n := k.(type) { + case object.Symbol: + return string(n) + case *object.String: + return n.Str() + } + return k.ToS() +} + +// avToS renders a Ruby value as a plain (unescaped) string the way #to_s would: +// nil is empty, a String yields its contents, a Symbol its name, and any other +// value its Ruby to_s. It is used for URL/label arguments where the library or a +// caller does any escaping. +func avToS(v object.Value) string { + switch x := v.(type) { + case nil, object.Nil: + return "" + case *object.String: + return x.Str() + case object.Symbol: + return string(x) + } + return v.ToS() +} + +// avOpts converts an optional options argument to the library's ordered Attrs: a +// Hash becomes one Attr per key in insertion order (matching Ruby hash ordering, +// which the byte-for-byte output depends on); nil / a missing / non-Hash argument +// yields no attributes. +func avOpts(v object.Value) actionview.Attrs { + h, ok := v.(*object.Hash) + if !ok { + return nil + } + out := make(actionview.Attrs, 0, len(h.Keys)) + for _, k := range h.Keys { + val, _ := h.Get(k) + out = append(out, actionview.Attr{Key: avKey(k), Val: avValue(val)}) + } + return out +} + +// avArg returns args[i] or nil when the index is past the end, so a helper can +// treat trailing arguments (value / content / options) as optional. +func avArg(args []object.Value, i int) object.Value { + if i < len(args) { + return args[i] + } + return nil +} + +// avOptHash returns the trailing options Hash of a helper call (args[i] when it is +// a Hash), or nil. Number/text helpers read their keyword options from it. +func avOptHash(args []object.Value, i int) *object.Hash { + if h, ok := avArg(args, i).(*object.Hash); ok { + return h + } + return nil +} + +// avHashVal looks a keyword option up in an options Hash by Symbol key then String +// key (Rails options use Symbol keys, but a String key is tolerated), reporting +// whether it was present. A nil Hash has no options. +func avHashVal(h *object.Hash, key string) (object.Value, bool) { + if h == nil { + return nil, false + } + if v, ok := h.Get(object.Symbol(key)); ok { + return v, true + } + return h.Get(object.NewString(key)) +} + +// avHashStr returns the string value of option key, or def when it is absent. +func avHashStr(h *object.Hash, key, def string) string { + if v, ok := avHashVal(h, key); ok { + return avToS(v) + } + return def +} + +// avHashInt returns the integer value of option key, or def when it is absent or +// not an Integer. +func avHashInt(h *object.Hash, key string, def int) int { + if v, ok := avHashVal(h, key); ok { + if i, ok := v.(object.Integer); ok { + return int(i) + } + } + return def +} + +// avHashBool returns the truthiness of option key, or def when it is absent. +func avHashBool(h *object.Hash, key string, def bool) bool { + if v, ok := avHashVal(h, key); ok { + return v.Truthy() + } + return def +} diff --git a/internal/vm/actionview_form.go b/internal/vm/actionview_form.go new file mode 100644 index 00000000..98d825d3 --- /dev/null +++ b/internal/vm/actionview_form.go @@ -0,0 +1,137 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + actionview "github.com/go-ruby-actionview/actionview" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// FormBuilderVal is a Ruby ActionView::Helpers::FormBuilder: the object-bound form +// field helper yielded by form_with. It wraps the library's *actionview.FormBuilder +// so each field method generates the exact user[field] name / user_field id +// conventions and the stored-value defaults the library reproduces. +type FormBuilderVal struct { + cls *RClass + fb *actionview.FormBuilder +} + +// ToS renders the default object form; a form builder has no string value. +func (b *FormBuilderVal) ToS() string { return "#" } + +// Inspect matches ToS. +func (b *FormBuilderVal) Inspect() string { return b.ToS() } + +// Truthy reports true. +func (b *FormBuilderVal) Truthy() bool { return true } + +// registerActionViewFormBuilder installs ActionView::Helpers::FormBuilder (the +// object yielded by form_with) and ActionView::Base#form_with, which opens a +// model-bound form, yields a FormBuilder to the block for the fields, and closes +// the form. It runs after registerActionViewBase so ActionView::Base already +// exists to reopen. +func (vm *VM) registerActionViewFormBuilder(mod *RClass) { + fbCls := newClass("ActionView::Helpers::FormBuilder", vm.cObject) + vm.consts["ActionView::Helpers::FormBuilder"] = fbCls + mod.consts["Helpers"] = vm.actionViewHelpersModule(mod, fbCls) + + fbCls.define("object_name", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self.(*FormBuilderVal).fb.ObjectName) + }) + fbCls.define("field_name", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(self.(*FormBuilderVal).fb.FieldName(avToS(avArg(args, 0)))) + }) + fbCls.define("field_id", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(self.(*FormBuilderVal).fb.FieldID(avToS(avArg(args, 0)))) + }) + + field := func(fn func(*actionview.FormBuilder, string, actionview.Attrs) actionview.SafeBuffer) NativeFn { + return func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*FormBuilderVal) + return vm.newSafeBuffer(fn(b.fb, avToS(avArg(args, 0)), avOpts(avArg(args, 1)))) + } + } + fbCls.define("text_field", field((*actionview.FormBuilder).TextField)) + fbCls.define("password_field", field((*actionview.FormBuilder).PasswordField)) + fbCls.define("hidden_field", field((*actionview.FormBuilder).HiddenField)) + fbCls.define("text_area", field((*actionview.FormBuilder).TextArea)) + + fbCls.define("label", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*FormBuilderVal) + return vm.newSafeBuffer(b.fb.Label(avToS(avArg(args, 0)), avToS(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + fbCls.define("check_box", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*FormBuilderVal) + return vm.newSafeBuffer(b.fb.CheckBox(avToS(avArg(args, 0)), avToS(avArg(args, 1)), avToS(avArg(args, 2)), avOpts(avArg(args, 3)))) + }) + fbCls.define("radio_button", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*FormBuilderVal) + return vm.newSafeBuffer(b.fb.RadioButton(avToS(avArg(args, 0)), avToS(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + fbCls.define("select", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*FormBuilderVal) + return vm.newSafeBuffer(b.fb.Select(avToS(avArg(args, 0)), avChoices(avArg(args, 1)), avOpts(avArg(args, 2)))) + }) + fbCls.define("submit", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := self.(*FormBuilderVal) + return vm.newSafeBuffer(b.fb.Submit(avToS(avArg(args, 0)), avOpts(avArg(args, 1)))) + }) + + base := vm.consts["ActionView::Base"].(*RClass) + base.define("form_with", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + b := self.(*ActionViewBase) + h, ok := avArg(args, 0).(*object.Hash) + if !ok { + raise("ArgumentError", "form_with expects an options Hash") + } + objectName := avHashStr(h, "scope", "") + url := avHashStr(h, "url", "") + persisted := avHashBool(h, "persisted", false) + var htmlOpts actionview.Attrs + if hv, ok := avHashVal(h, "html"); ok { + htmlOpts = avOpts(hv) + } + out := b.context().FormWith(objectName, avModelMap(h), url, htmlOpts, persisted, + func(fb *actionview.FormBuilder) actionview.SafeBuffer { + if blk == nil { + return actionview.SafeBuffer{} + } + return avToSafeBuffer(vm.callBlock(blk, []object.Value{&FormBuilderVal{cls: fbCls, fb: fb}})) + }) + return vm.newSafeBuffer(out) + }) +} + +// actionViewHelpersModule returns the ActionView::Helpers module, holding the +// FormBuilder constant so ActionView::Helpers::FormBuilder resolves by its full +// name. +func (vm *VM) actionViewHelpersModule(mod *RClass, fbCls *RClass) *RClass { + helpers := newClass("ActionView::Helpers", nil) + helpers.isModule = true + helpers.consts["FormBuilder"] = fbCls + vm.consts["ActionView::Helpers"] = helpers + return helpers +} + +// avModelMap reads the form_with :model option (a Hash of attribute values) into +// the map[string]any the FormBuilder reads field values from. A missing / non-Hash +// :model yields an empty map (a form with no seeded values). +func avModelMap(h *object.Hash) map[string]any { + mv, ok := avHashVal(h, "model") + if !ok { + return map[string]any{} + } + mh, ok := mv.(*object.Hash) + if !ok { + return map[string]any{} + } + out := make(map[string]any, len(mh.Keys)) + for _, k := range mh.Keys { + val, _ := mh.Get(k) + out[avKey(k)] = avValue(val) + } + return out +} diff --git a/internal/vm/actionview_internal_test.go b/internal/vm/actionview_internal_test.go new file mode 100644 index 00000000..7e132df8 --- /dev/null +++ b/internal/vm/actionview_internal_test.go @@ -0,0 +1,382 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + "io" + "testing" + + actionview "github.com/go-ruby-actionview/actionview" + erb "github.com/go-ruby-erb/erb" + + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-ruby-parser/parser" + "github.com/go-ruby-parser/parser/ast" +) + +// avRun compiles and runs src on v, returning the value of its last expression and +// failing the test on any parse/compile/runtime error. +func avRun(t *testing.T, v *VM, src string) object.Value { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse %q: %v", src, err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile %q: %v", src, err) + } + res, rerr := v.Run(iseq) + if rerr != nil { + t.Fatalf("run %q: %v", src, rerr) + } + return res +} + +// avEval runs src and returns its result rendered as a string (a SafeBuffer's +// markup or any value's to_s). +func avEval(t *testing.T, v *VM, src string) string { + t.Helper() + return avRun(t, v, src).ToS() +} + +// avEq runs src and asserts its string result equals want. +func avEq(t *testing.T, v *VM, src, want string) { + t.Helper() + if got := avEval(t, v, src); got != want { + t.Errorf("src=%s\n got=%q\nwant=%q", src, got, want) + } +} + +// avRunErr runs src expecting a RubyError and returns its class. +func avRunErr(t *testing.T, v *VM, src string) string { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse %q: %v", src, err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile %q: %v", src, err) + } + _, rerr := v.Run(iseq) + re, ok := rerr.(RubyError) + if !ok { + t.Fatalf("src=%q: expected a RubyError, got %#v", src, rerr) + } + return re.Class +} + +// TestActionViewSafeBuffer covers the ActiveSupport::SafeBuffer surface and +// String#html_safe / #html_safe?. +func TestActionViewSafeBuffer(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `"".html_safe.html_safe?`, "true") + avEq(t, v, `"".html_safe?`, "false") + avEq(t, v, `"".html_safe.class.name`, "ActiveSupport::SafeBuffer") + avEq(t, v, `ActiveSupport::SafeBuffer.new.empty?`, "true") + avEq(t, v, `ActiveSupport::SafeBuffer.new("hi").length`, "2") + avEq(t, v, `ActiveSupport::SafeBuffer.new("hi").to_s`, "hi") + avEq(t, v, `ActiveSupport::SafeBuffer.new("hi").to_str`, "hi") + avEq(t, v, `s = "hi".html_safe; (s.html_safe.equal?(s)).to_s`, "true") + // << escapes an unsafe String, appends a SafeBuffer verbatim; safe_concat is + // verbatim; concat is the << alias. + avEq(t, v, `b = ActiveSupport::SafeBuffer.new("a"); b << ""; b.to_s`, "a<x>") + avEq(t, v, `b = ActiveSupport::SafeBuffer.new("a"); b << "".html_safe; b.to_s`, "a") + avEq(t, v, `b = ActiveSupport::SafeBuffer.new("a"); b.concat(""); b.to_s`, "a<y>") + avEq(t, v, `b = ActiveSupport::SafeBuffer.new("a"); b.safe_concat(""); b.to_s`, "a") + // The OutputBuffer alias resolves to the same class. + avEq(t, v, `ActionView::OutputBuffer.new("q").to_s`, "q") +} + +// TestActionViewOutputSafety covers raw / html_escape / h. +func TestActionViewOutputSafety(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `ActionView::Base.new.raw("").to_s`, "") + avEq(t, v, `ActionView::Base.new.h("").to_s`, "<b>") + avEq(t, v, `ActionView::Base.new.html_escape("").to_s`, "<b>") + // h on an already-safe buffer returns it unescaped. + avEq(t, v, `ActionView::Base.new.h("".html_safe).to_s`, "") +} + +// TestActionViewTagHelpers covers content_tag (positional + block), tag, +// token_list/class_names and cdata_section, plus the avValue/avKey fallbacks. +func TestActionViewTagHelpers(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `ActionView::Base.new.content_tag(:p, "hi", class: "x")`, `

hi

`) + avEq(t, v, `ActionView::Base.new.content_tag(:div) { "" }`, `
<b>
`) + avEq(t, v, `ActionView::Base.new.content_tag(:div) { "".html_safe }`, `
`) + avEq(t, v, `ActionView::Base.new.tag(:br)`, `
`) + avEq(t, v, `ActionView::Base.new.tag(:circle)`, ``) + avEq(t, v, `ActionView::Base.new.token_list("a", "b", "a")`, "a b") + avEq(t, v, `ActionView::Base.new.class_names(["a", {"b" => true, "c" => false}])`, "a b") + avEq(t, v, `ActionView::Base.new.cdata_section("x]]>y")`, "y]]>") + // avValue fallback (a Range -> to_s), int/float/bool/symbol attr values. + avEq(t, v, `ActionView::Base.new.content_tag(:p, (1..3))`, `

1..3

`) + avEq(t, v, `ActionView::Base.new.tag(:input, nil, {"data-n" => 5, "checked" => true, "x" => :y})`, + ``) + // avKey fallback: a non-String/Symbol attribute key renders via to_s. + avEq(t, v, `ActionView::Base.new.tag(:span, nil, {1 => "a"})`, ``) +} + +// TestActionViewUrlHelpers covers link_to (positional + block), mail_to, button_to +// and current_page? / url_for, plus the avToS fallback. +func TestActionViewUrlHelpers(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `ActionView::Base.new.link_to("Home", "/home", class: "nav")`, `Home`) + avEq(t, v, `ActionView::Base.new.link_to("/go") { "Go" }`, `Go`) + // avToS fallback on a non-String url argument. + avEq(t, v, `ActionView::Base.new.link_to("t", 123)`, `t`) + avEq(t, v, `ActionView::Base.new.mail_to("a@b.com")`, `a@b.com`) + avEq(t, v, `ActionView::Base.new.button_to("Del", "/x", method: "delete").include?("_method")`, "true") + avEq(t, v, `ActionView::Base.new.button_to("Go", "/y").include?("button_to")`, "true") + + // current_page? / url_for against configured request state. + avEq(t, v, `b = ActionView::Base.new; b.request_path = "/home"; b.request_method = "get"; b.current_page?("/home").to_s`, "true") + avEq(t, v, `b = ActionView::Base.new; b.request_path = "/home"; b.current_page?("/other").to_s`, "false") + avEq(t, v, `b = ActionView::Base.new; b.request_path = "/p"; b.request_fullpath = "/p?q=1"; b.current_page?("/p?q=1").to_s`, "true") + avEq(t, v, `ActionView::Base.new.url_for("/plain")`, "/plain") +} + +// TestActionViewUrlForSeam covers the url_for routes seam: a wired Ruby callable +// and the avProc no-op (a non-Proc assignment clears it). +func TestActionViewUrlForSeam(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `b = ActionView::Base.new; b.url_for = ->(x){ "/r/#{x}" }; b.url_for("home")`, "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/r/home") + // A non-Proc assignment leaves no callable, so url_for falls back to identity. + avEq(t, v, `b = ActionView::Base.new; b.url_for = 5; b.url_for("/plain")`, "/plain") +} + +// TestActionViewFormTagHelpers covers the object-independent form helpers. +func TestActionViewFormTagHelpers(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `ActionView::Base.new.text_field_tag("q", "hi")`, ``) + avEq(t, v, `ActionView::Base.new.password_field_tag("pw")`, ``) + avEq(t, v, `ActionView::Base.new.hidden_field_tag("h", "1").include?("hidden")`, "true") + avEq(t, v, `ActionView::Base.new.text_area_tag("body", "txt")`, "") + avEq(t, v, `ActionView::Base.new.check_box_tag("ok", "1", true).include?("checked")`, "true") + avEq(t, v, `ActionView::Base.new.check_box_tag("ok").include?("checked")`, "false") + avEq(t, v, `ActionView::Base.new.radio_button_tag("c", "r", true).include?("checked")`, "true") + avEq(t, v, `ActionView::Base.new.radio_button_tag("c", "r").include?("checked")`, "false") + avEq(t, v, `ActionView::Base.new.label_tag("name")`, ``) + avEq(t, v, `ActionView::Base.new.submit_tag.include?("Save changes")`, "true") + avEq(t, v, `ActionView::Base.new.button_tag { "Go" }`, ``) + avEq(t, v, `ActionView::Base.new.button_tag("Save")`, ``) + + // options_for_select: string elements and [text, value] pairs, with a selected + // value; then select_tag wrapping option markup (String path) with include_blank. + avEq(t, v, `ActionView::Base.new.options_for_select(["a", "b"], "b")`, + "\n") + avEq(t, v, `ActionView::Base.new.options_for_select([["Text", "v"]])`, ``) + // avChoices nil branch (non-Array container) yields no options. + avEq(t, v, `ActionView::Base.new.options_for_select("x").to_s`, "") + avEq(t, v, `ActionView::Base.new.select_tag("s", "", include_blank: true).include?("label")`, "true") + + // form_tag: block (complete form) and non-block (open tag only), and the + // utf8/method/CSRF hidden fields toggled by configuration. + avEq(t, v, `b = ActionView::Base.new; b.form_tag("/y") { b.text_field_tag("q") }.include?("")`, "true") + avEq(t, v, `b = ActionView::Base.new; b.protect_against_forgery = true; b.authenticity_token = "tok"; b.form_tag("/x", method: "patch").include?("authenticity_token")`, "true") + avEq(t, v, `b = ActionView::Base.new; b.suppress_utf8_enforcer = true; b.form_tag("/x").include?("utf8")`, "false") +} + +// TestActionViewFormBuilder covers form_with and the yielded FormBuilder methods, +// plus the avModelMap variants. +func TestActionViewFormBuilder(t *testing.T) { + v := New(io.Discard) + src := ` +b = ActionView::Base.new +b.form_with(scope: "user", url: "/users", model: {"email" => "a@b.com", "admin" => true}) do |f| + f.label(:email) + f.text_field(:email) + f.password_field(:password) + + f.hidden_field(:token) + f.text_area(:bio) + f.check_box(:admin) + + f.radio_button(:role, "x") + f.select(:role, ["a", "b"]) + f.submit + + f.object_name + f.field_name(:email) + f.field_id(:email) +end` + out := avEval(t, v, src) + for _, want := range []string{ + `name="user[email]"`, `id="user_email"`, `value="a@b.com"`, `type="password"`, + `checked="checked"`, `")`, "true") +} + +// TestActionViewTextHelpers covers the TextHelper family. +func TestActionViewTextHelpers(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `ActionView::Base.new.truncate("Hello World", length: 8)`, "Hello...") + // avHashInt non-Integer branch (length is a String) falls back to the default 30. + avEq(t, v, `ActionView::Base.new.truncate("short", length: "bad")`, "short") + // avHashVal String-key lookup (Symbol key absent, String key present). + avEq(t, v, `ActionView::Base.new.truncate("Hello World", "length" => 8)`, "Hello...") + avEq(t, v, `ActionView::Base.new.pluralize(2, "person", "people")`, "2 people") + avEq(t, v, `ActionView::Base.new.pluralize(1, "person")`, "1 person") + avEq(t, v, `ActionView::Base.new.simple_format("a\n\nb", {}, wrapper_tag: "div").include?("
")`, "true") + avEq(t, v, `ActionView::Base.new.word_wrap("aa bb cc", line_width: 5)`, "aa bb\ncc") + avEq(t, v, `ActionView::Base.new.highlight("hello world", "world")`, "hello world") + avEq(t, v, `ActionView::Base.new.highlight("a b c", ["a", "c"], highlighter: "[\\1]")`, "[a] b [c]") + avEq(t, v, `ActionView::Base.new.excerpt("this is a test", "is", radius: 3)`, "this is...") + // excerpt not-found returns nil. + avEq(t, v, `ActionView::Base.new.excerpt("abc", "zzz").inspect`, "nil") +} + +// TestActionViewNumberHelpers covers all eight NumberHelper methods and the number +// options mapping. +func TestActionViewNumberHelpers(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `ActionView::Base.new.number_to_currency(1234.5)`, "$1,234.50") + avEq(t, v, `ActionView::Base.new.number_to_currency(9.5, unit: "€", precision: 0, format: "%n%u", separator: ",", delimiter: ".", negative_format: "(%n)")`, "10€") + avEq(t, v, `ActionView::Base.new.number_with_delimiter(12345)`, "12,345") + avEq(t, v, `ActionView::Base.new.number_to_delimited(12345)`, "12,345") + avEq(t, v, `ActionView::Base.new.number_with_precision(3.14159, precision: 2)`, "3.14") + avEq(t, v, `ActionView::Base.new.number_to_rounded(3.14159, precision: 1)`, "3.1") + avEq(t, v, `ActionView::Base.new.number_to_percentage(66.667, precision: 1)`, "66.7%") + avEq(t, v, `ActionView::Base.new.number_to_human_size(1536)`, "1.5 KB") + avEq(t, v, `ActionView::Base.new.number_to_human(1234567, significant: true, strip_insignificant_zeros: true)`, "1.23 Million") +} + +// TestActionViewRenderInline covers the default inline-ERB render seam: locals, a +// helper call inside the template, an @ivar, and a collection with the +// PartialIteration and counter locals. +func TestActionViewRenderInline(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `ActionView::Base.new.render(inline: "Hi <%= name %>", locals: {name: "Bob"}).to_s`, "Hi Bob") + // A helper method dispatches on self inside the template. + avEq(t, v, `ActionView::Base.new.render(inline: "<%= content_tag(:b, name) %>", locals: {name: "x"}).to_s`, "x") + // An @ivar set on the context resolves in the template (ivarTable seam). + avEq(t, v, `b = ActionView::Base.new; b.instance_variable_set(:@g, "Hey"); b.render(inline: "<%= @g %>").to_s`, "Hey") + // A collection binds each element plus its _counter and _iteration; spacer joins. + avEq(t, v, `ActionView::Base.new.render(partial: "<%= item %>@<%= item_iteration.index %>/<%= item_iteration.size %>:<%= item_iteration.first? %>:<%= item_iteration.last? %>:<%= item_counter %>", collection: ["a", "b"], as: "item", spacer_template: "|").to_s`, + "a@0/2:true:false:0|b@1/2:false:true:1") + // A locals-less inline render. + avEq(t, v, `ActionView::Base.new.render(inline: "plain").to_s`, "plain") +} + +// TestActionViewRenderSeam covers the render_template callable seam and the render +// error branches. +func TestActionViewRenderSeam(t *testing.T) { + v := New(io.Discard) + // A wired render_template resolver receives the identifier and locals Hash. + avEq(t, v, `b = ActionView::Base.new; b.render_template = ->(id, locals){ "R:#{id}:#{locals["x"]}" }; b.render(template: "show", locals: {x: 7}).to_s`, "R:show:7") + // A partial identifier routes through the same seam. + avEq(t, v, `b = ActionView::Base.new; b.render_template = ->(id, _){ "P:#{id}" }; b.render(partial: "row").to_s`, "P:row") + // A non-Proc render_template assignment clears the seam (falls back to inline). + avEq(t, v, `b = ActionView::Base.new; b.render_template = nil; b.render(inline: "ok").to_s`, "ok") + // render with no source key and with a non-Hash argument both raise ArgumentError. + if c := avRunErr(t, v, `ActionView::Base.new.render({})`); c != "ArgumentError" { + t.Errorf("render({}) class=%q want ArgumentError", c) + } + if c := avRunErr(t, v, `ActionView::Base.new.render(5)`); c != "ArgumentError" { + t.Errorf("render(5) class=%q want ArgumentError", c) + } +} + +// TestActionViewInlineRenderErrors covers the three inline-render error branches +// via fault injection (an ERB compile failure and the two front-end failures). +func TestActionViewInlineRenderErrors(t *testing.T) { + v := New(io.Discard) + + origCompile := erbCompile + erbCompile = func(string, erb.Options) (string, string, error) { + return "", "", errors.New("boom") + } + if c := avRunErr(t, v, `ActionView::Base.new.render(inline: "x").to_s`); c != "ArgumentError" { + t.Errorf("erb-compile failure class=%q want ArgumentError", c) + } + erbCompile = origCompile + + origParse := avParse + avParse = func(string) (*ast.Program, error) { return nil, errors.New("bad parse") } + if c := avRunErr(t, v, `ActionView::Base.new.render(inline: "x").to_s`); c != "SyntaxError" { + t.Errorf("parse failure class=%q want SyntaxError", c) + } + avParse = origParse + + origCompileL := avCompileWithLocals + avCompileWithLocals = func(*ast.Program, []string) (*bytecode.ISeq, error) { + return nil, errors.New("bad compile") + } + if c := avRunErr(t, v, `ActionView::Base.new.render(inline: "x").to_s`); c != "SyntaxError" { + t.Errorf("compile failure class=%q want SyntaxError", c) + } + avCompileWithLocals = origCompileL +} + +// TestActionViewEdgeCases covers the remaining option-mapping branches: a +// non-Hash :locals, an options Hash carrying both a consumed key and an extra HTML +// attribute (the avOptsExcept append path), and form_with's html option and +// non-Hash argument. +func TestActionViewEdgeCases(t *testing.T) { + v := New(io.Discard) + // A non-Hash :locals is ignored (no locals bound). + avEq(t, v, `ActionView::Base.new.render(inline: "x", locals: 5).to_s`, "x") + // form_tag with method: (consumed) and class: (kept) exercises avOptsExcept's + // skip-and-append branches together. + avEq(t, v, `ActionView::Base.new.form_tag("/x", method: "patch", class: "f").include?("class=\"f\"")`, "true") + // form_with with an :html attribute Hash. + avEq(t, v, `ActionView::Base.new.form_with(scope: "u", url: "/u", html: {class: "f"}) { |f| f.text_field(:x) }.include?("class=\"f\"")`, "true") + // form_with with a non-Hash argument raises ArgumentError. + if c := avRunErr(t, v, `ActionView::Base.new.form_with(5)`); c != "ArgumentError" { + t.Errorf("form_with(5) class=%q want ArgumentError", c) + } +} + +// TestActionViewValueMethods covers the object.Value method set (ToS/Inspect/ +// Truthy) on the binding's value structs directly, and the truthiness path a +// conditional exercises. +func TestActionViewValueMethods(t *testing.T) { + v := New(io.Discard) + b := v.newActionViewBase() + if b.ToS() != "#" || b.Inspect() != "#" || !b.Truthy() { + t.Errorf("ActionViewBase value methods: %q / %q / %v", b.ToS(), b.Inspect(), b.Truthy()) + } + sb := v.newSafeBuffer(actionview.Raw("hi")) + if sb.ToS() != "hi" || sb.Inspect() != `"hi"` || !sb.Truthy() { + t.Errorf("SafeBufferVal value methods: %q / %q / %v", sb.ToS(), sb.Inspect(), sb.Truthy()) + } + pi := &AVPartialIter{cls: avPartialIterClass, p: actionview.PartialIteration{Index: 0, Size: 1}} + if pi.ToS() != "#" || pi.Inspect() != pi.ToS() || !pi.Truthy() { + t.Errorf("AVPartialIter value methods: %q / %q / %v", pi.ToS(), pi.Inspect(), pi.Truthy()) + } + fb := &FormBuilderVal{cls: v.consts["ActionView::Helpers::FormBuilder"].(*RClass), fb: actionview.FormBuilderFor("u", nil)} + if fb.ToS() != "#" || fb.Inspect() != fb.ToS() || !fb.Truthy() { + t.Errorf("FormBuilderVal value methods: %q / %q / %v", fb.ToS(), fb.Inspect(), fb.Truthy()) + } + // The Truthy path through a Ruby conditional. + avEq(t, v, `("x".html_safe) ? "y" : "n"`, "y") +} + +// TestActionViewProvidedFeatures covers the require registration (require returns +// true and the constant is available with no .rb file). +func TestActionViewProvidedFeatures(t *testing.T) { + v := New(io.Discard) + avEq(t, v, `require "action_view"`, "true") + avEq(t, v, `require "actionview"`, "true") + avEq(t, v, `defined?(ActionView::Base)`, "constant") +} + +// containsAV reports whether s contains sub (a tiny helper to keep the form_with +// assertions readable without importing strings in every arm). +func containsAV(s, sub string) bool { + return len(sub) == 0 || indexAV(s, sub) >= 0 +} + +func indexAV(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/vm/actionview_render_closed.go b/internal/vm/actionview_render_closed.go new file mode 100644 index 00000000..22215c19 --- /dev/null +++ b/internal/vm/actionview_render_closed.go @@ -0,0 +1,21 @@ +//go:build rbgo_closed + +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +// actionViewInlineRender is unavailable in a closed-world binary: rendering an +// inline ERB template evaluates its compiled Ruby source, which needs the +// front-end (parser + CompileWithLocals) a closed-world build drops from the link +// — the same reason Binding#eval and the Sinatra erb helper raise there. Every +// other ActionView helper (the pure tag/url/form/text/number helpers and the +// SafeBuffer) still works, and a host that wires a render_template callable seam +// (e.g. actionpack) renders without this default path; only the built-in inline +// ERB default raises. +func (vm *VM) actionViewInlineRender(b *ActionViewBase, tmpl string, locals map[string]any) string { + _, _, _ = b, tmpl, locals + raise("NotImplementedError", "ActionView inline template rendering is unavailable in a closed-world binary (built with rbgo build --closed, without the front-end)") + return "" // unreachable: raise panics +} diff --git a/internal/vm/actionview_render_open.go b/internal/vm/actionview_render_open.go new file mode 100644 index 00000000..b1c7927b --- /dev/null +++ b/internal/vm/actionview_render_open.go @@ -0,0 +1,77 @@ +//go:build !rbgo_closed + +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + erb "github.com/go-ruby-erb/erb" + + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-ruby-parser/parser" +) + +// avParse and avCompileWithLocals are seams over the front-end used by the inline +// renderer, letting a fault-injection test drive the SyntaxError branches (a parse +// failure and a post-parse compile failure) deterministically rather than relying +// on a template that happens to break at each stage. +var ( + avParse = parser.Parse + avCompileWithLocals = compiler.CompileWithLocals +) + +// actionViewInlineRender is the default RenderTemplate seam: it evaluates tmpl as +// an inline ERB template against the ActionView::Base context, so <%= %> runs Ruby +// with the render locals bound as local variables and helper methods dispatching +// on self. The template is compiled through the already-bound go-ruby-erb compiler +// in erubi mode (Rails' default engine, so a standalone <%= %> line keeps its +// trailing newline), then evaluated exactly as the Sinatra erb helper evaluates a +// view: a synthetic parent frame holds the locals so the compiled source resolves +// them at depth 1, and self is the Base (whose @ivars and helper methods resolve). +// The compiled template's final expression is its output buffer, so exec returns +// the rendered String. It uses the front-end directly (parser + CompileWithLocals), +// so a closed-world build replaces it with the stub in actionview_render_closed.go. +func (vm *VM) actionViewInlineRender(b *ActionViewBase, tmpl string, locals map[string]any) string { + names, vals := avLocalSlots(locals) + src, _, err := erbCompile(tmpl, erb.Options{Mode: erb.ModeErubi, EOutVar: "_erbout"}) + if err != nil { + // go-ruby-erb never fails on a well-formed template (its err is reserved for + // malformed options, unreachable through this fixed-option call, so the branch + // is exercised by swapping erbCompile in a fault-injection test); it is + // surfaced as an ArgumentError so the contract stays total. + raise("ArgumentError", "%s", err.Error()) + } + prog, perr := avParse(src) + if perr != nil { + // A parse error means the template's embedded Ruby (<% … %>) is itself + // malformed — surfaced as a SyntaxError, matching MRI's eval of a broken + // compiled template. + raise("SyntaxError", "%s", perr.Error()) + } + iseq, cerr := avCompileWithLocals(prog, names) + if cerr != nil { + // A compile error on source the front-end parsed (e.g. a construct illegal at + // this position); surfaced as a SyntaxError. + raise("SyntaxError", "%s", cerr.Error()) + } + iseq.Name = "(actionview)" + slots := make([]object.Value, len(vals)) + copy(slots, vals) + parent := &Env{slots: slots} + res := vm.exec(iseq, b, nil, b.cls, "", parent, nil, nil, nil) + return avToS(res) +} + +// avLocalSlots splits the render locals into a stable-ordered name list and the +// matching Ruby values (each mapped through avLocalToRuby), the shape +// CompileWithLocals and the synthetic parent env consume. +func avLocalSlots(locals map[string]any) (names []string, vals []object.Value) { + for k, v := range locals { + names = append(names, k) + vals = append(vals, avLocalToRuby(v)) + } + return names, vals +} diff --git a/internal/vm/arith.go b/internal/vm/arith.go index 02c4dbf9..01b47404 100644 --- a/internal/vm/arith.go +++ b/internal/vm/arith.go @@ -237,6 +237,12 @@ func (vm *VM) binaryOp(op bytecode.Op, a, b object.Value) object.Value { if _, isRF := a.(*ProtobufRepeatedField); isRF { return vm.send(a, arithOpName(op), []object.Value{b}, nil) } + // An ActiveSupport::SafeBuffer dispatches its + (a new html-safe buffer with + // the right operand escaped-unless-safe) as a method, so a view can build + // markup with `f.label + f.text_field` rather than hitting the coercion path. + if _, isSB := a.(*SafeBufferVal); isSB { + return vm.send(a, arithOpName(op), []object.Value{b}, nil) + } return binary(op, a, b) } } diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 297665ed..ee077400 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -950,6 +950,14 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["ActiveSupport::Inflector::Inflections"].(*RClass) case *ErubiEngine: return x.cls + case *SafeBufferVal: + return x.cls + case *ActionViewBase: + return x.cls + case *AVPartialIter: + return x.cls + case *FormBuilderVal: + return x.cls case *HTTPrbClient: return vm.consts["HTTP::Client"].(*RClass) case *HTTPrbResponse: @@ -1748,6 +1756,11 @@ func ivarTable(self object.Value) map[string]object.Value { o.ivars = map[string]object.Value{} } return o.ivars + case *ActionViewBase: + if o.ivars == nil { + o.ivars = map[string]object.Value{} + } + return o.ivars } return nil } diff --git a/internal/vm/require.go b/internal/vm/require.go index 1ce3dd7d..4f323524 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -78,6 +78,7 @@ var providedFeatures = map[string]bool{ "active_job": true, "activejob": true, "active_storage": true, "activestorage": true, "action_cable": true, "actioncable": true, + "action_view": true, "actionview": true, "rails": true, "rails/railtie": true, "rails/engine": true, "rails/application": true, "devise": true, "hanami": true, "hanami/router": true, "hanami/action": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 94d4017c..1c586b87 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -604,6 +604,7 @@ func New(out io.Writer) *VM { vm.installPrelude() vm.registerEnumerator() // after the prelude so it can mix in Enumerable vm.registerActiveSupport() // ActiveSupport::Inflector + core extensions (require "active_support" / "active_support/all"), backed by go-ruby-activesupport; after the prelude so the Enumerable module (which its core-ext extends) exists + vm.registerActionView() // ActionView::Base view context (tag/url/form/text/number helpers + render) + FormBuilder + PartialIteration + ActiveSupport::SafeBuffer / String#html_safe (require "action_view"), backed by go-ruby-actionview; the URLFor (routes) and RenderTemplate seams wire to Ruby callables, the inline-render default evals ERB through the already-bound go-ruby-erb compiler; after registerActiveSupport (SafeBuffer nests under ActiveSupport) and after the bootstrap ERB/Erubi registration (escaping/compiler surface); #render stays a dispatchable method for a later actionpack/actionmailer binding vm.registerLazy() // after Enumerator (Enumerator::Lazy is built on it) vm.registerFileStat() // File::Stat / FileTest; after the prelude so File::Stat can mix in Comparable vm.registerIPAddr() // IPAddr (require "ipaddr"), backed by go-ruby-ipaddr; after the prelude so IPAddr can mix in Comparable From 64040498fb01bbfd077c9442c84523cddcce1307 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 23:19:29 +0200 Subject: [PATCH 4/5] Bind pure-Go actionpack into rbgo (ActionController + ActionDispatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind github.com/go-ruby-actionpack/actionpack — a pure-Go (CGO=0) port of Rails' Action Pack — into rbgo as a native module set, so `require "action_controller"` / `require "action_dispatch"` work with no .rb file. The four subpackages are wired across five files: - routing: ActionDispatch::Routing::RouteSet + Mapper — the routes `draw` DSL (verb matchers, resources/resource, root, namespace/scope, member/collection, constraints, all option keys) plus recognize / path / path_args / url_for reverse routing. - controller: ActionController::Base (over AbstractController::Base + ActionController::Metal) — before/after/around filters (only/except/if/ unless), rescue_from, render/redirect_to/head, and PostsController.dispatch. - parameters: ActionController::Parameters — permit/require/[]/to_h/merge strong-parameters, with the ParameterMissing/UnpermittedParameters/ UnfilteredParameters errors. - dispatch: ActionDispatch::Request / Response over the bound go-ruby-rack. Every seam runs inline on the VM goroutine under the GVL: the Ruby action method (RunAction), the filter/rescue bodies (blocks or named methods), and the view render — the Renderer seam sends a `render` message to an ActionView-style context set with `view_context`, so the consolidated binary dispatches to the bound actionview while this clone tests it with an injected Ruby renderer stub. A controller instance is a plain RObject of the user's subclass whose live *controller.Base is reached through the @__ac handle (mirroring the ActionCable channel binding); the routing/dispatch/params value objects each carry their Ruby class for classOf. actionpack does not import actionview — rendering is the seam above. registerActionPack joins the bootstrap after registerRack/registerActionCable; providedFeatures gains action_controller/action_dispatch/actionpack/ abstract_controller; classOf gains the RouteSet/Mapper/Parameters/Request/ Response cases. 100% coverage of all new files (routing recognize/resources + reverse-routing error paths, filters/rescue_from, strong-params permit/require raise paths, the RunAction/around/rescue/Renderer seams, double-render), full internal/vm + internal/object suites green, gofmt/go vet clean, builds on all 6 64-bit arches (amd64/arm64/riscv64/loong64/ppc64le/s390x). Co-Authored-By: Claude Opus 4.8 --- go.mod | 1 + go.sum | 2 + internal/vm/actionpack.go | 277 ++++++++++ internal/vm/actionpack_controller.go | 498 +++++++++++++++++ internal/vm/actionpack_dispatch.go | 125 +++++ internal/vm/actionpack_internal_test.go | 701 ++++++++++++++++++++++++ internal/vm/actionpack_params.go | 132 +++++ internal/vm/actionpack_routing.go | 339 ++++++++++++ internal/vm/builtins.go | 1 + internal/vm/object_model.go | 10 + internal/vm/require.go | 1 + internal/vm/vm.go | 7 + 12 files changed, 2094 insertions(+) create mode 100644 internal/vm/actionpack.go create mode 100644 internal/vm/actionpack_controller.go create mode 100644 internal/vm/actionpack_dispatch.go create mode 100644 internal/vm/actionpack_internal_test.go create mode 100644 internal/vm/actionpack_params.go create mode 100644 internal/vm/actionpack_routing.go diff --git a/go.mod b/go.mod index cfca96e2..6a7d33d9 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-ruby-abbrev/abbrev v0.0.0-20260629150957-97117892cd38 github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 + github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5 github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a github.com/go-ruby-activerecord/activerecord v0.0.0-20260702222646-da57bd9e07f6 diff --git a/go.sum b/go.sum index fdcd8f98..d1ddf1b7 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe h1:oYFDl+kcAkTOX github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe/go.mod h1:Bh8Vr5Vy9UO7JqU5f6myUOWl0jjnnvtlGdIVTBbdLRw= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 h1:VAFOmKa1u2HKA2LFQhelusPVIAtgp+wAOkXl8IjURbQ= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783/go.mod h1:5DS3icNZKImRwvDP7Kf9IHv+9d6EYtcvRpBALgh/s/Y= +github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5 h1:+CxMtA9K0j7BWiWdq+/rMfgeWjViJStyfVKcvMlDSvA= +github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5/go.mod h1:3lXE8Ou6UitS4sOxQhn/KCI6eKLdREUcFxsky4oMEbk= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 h1:vRJAWboG75rQWpfTiNQ8XkFRJ1Am9RNKdRKSlTLQBiA= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4/go.mod h1:HBZ9KJKoGTzo4h6/5+gaYyzMYWQtucT660mE68zHZGw= github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a h1:d8kCh4DxIJEQJhd/ZD6niTnxEzWgnZ5NlOmxfdBRDZQ= diff --git a/internal/vm/actionpack.go b/internal/vm/actionpack.go new file mode 100644 index 00000000..4f6156c7 --- /dev/null +++ b/internal/vm/actionpack.go @@ -0,0 +1,277 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/dispatch" + "github.com/go-ruby-actionpack/actionpack/parameters" + "github.com/go-ruby-actionpack/actionpack/routing" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file (with actionpack_routing.go, actionpack_controller.go, +// actionpack_params.go and actionpack_dispatch.go) binds +// github.com/go-ruby-actionpack/actionpack — a pure-Go (CGO=0) port of the four +// deterministic cores of Rails' Action Pack: the routing DSL/route set +// (ActionDispatch::Routing), the controller dispatch core (AbstractController + +// ActionController::Metal), strong parameters (ActionController::Parameters) and +// the HTTP layer (ActionDispatch::Request/Response) — into rbgo (require +// "action_controller" / "action_dispatch"): +// +// class PostsController < ActionController::Base +// before_action :authenticate, except: [:index] +// rescue_from ArgumentError do |e| render plain: "bad: #{e.message}", status: 400 end +// def index; render plain: "posts"; end +// def show; render plain: "post #{params[:id]}"; end +// end +// PostsController.dispatch(:index, env) # => [status, headers, body] Rack triple +// +// routes = ActionDispatch::Routing::RouteSet.new +// routes.draw { resources :posts; root to: "home#index" } +// routes.recognize("GET", "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/posts/1") # => {"controller"=>"posts", "action"=>"show", "id"=>"1"} +// routes.path("post", id: 1) # => "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/posts/1" +// +// The library owns the route pattern compiler + recognizer + reverse routing, +// the filter/rescue/render dispatch pipeline and the strong-parameters +// permit/require semantics. rbgo supplies the seams the library leaves +// injectable, all run inline on the VM goroutine under the GVL: +// +// - RunAction — the controller's Ruby action method (`def index; …; end`), +// sent to the controller instance; the primary seam. +// - the before/after/around filter and rescue_from bodies — Ruby blocks or +// named instance methods, sent to the controller instance. +// - Renderer — the view-layer seam: it sends a `render` message to an +// ActionView-style context object (set with `view_context`), so the +// consolidated binary dispatches to the bound actionview; the library owns +// the plain/status/content-type response assembly. +// +// actionpack does NOT import actionview: rendering is the Renderer seam above. +// The routing/dispatch value objects (RouteSet/Mapper/Parameters/Request/ +// Response) each carry their Ruby class so classOf reports it; a controller +// instance is a plain RObject of the user's subclass whose live *controller.Base +// is reached through the @__ac handle (mirroring the ActionCable channel binding). + +// ACRouteSet is the Ruby wrapper over a *routing.RouteSet — the ordered route +// collection built by ActionDispatch::Routing::RouteSet.new and its `draw` +// block, queried with recognize/path/url_for. It carries its class so classOf +// reports ActionDispatch::Routing::RouteSet. +type ACRouteSet struct { + rs *routing.RouteSet + cls *RClass +} + +func (s *ACRouteSet) ToS() string { return "#" } +func (s *ACRouteSet) Inspect() string { return s.ToS() } +func (s *ACRouteSet) Truthy() bool { return true } + +// ACMapper is the self the routes DSL runs against inside `draw do … end` — a +// thin wrapper over the library's *routing.Mapper exposing the verb matchers, +// resources/resource, root, namespace/scope and member/collection blocks. +type ACMapper struct { + m *routing.Mapper + cls *RClass +} + +func (m *ACMapper) ToS() string { return "#" } +func (m *ACMapper) Inspect() string { return m.ToS() } +func (m *ACMapper) Truthy() bool { return true } + +// ACParams is the Ruby wrapper over a *parameters.Parameters — Rails' strong +// parameters, with permit/require/[]/to_h. It carries its class so classOf +// reports ActionController::Parameters. +type ACParams struct { + p *parameters.Parameters + cls *RClass +} + +func (p *ACParams) ToS() string { return p.p.String() } +func (p *ACParams) Inspect() string { return p.p.String() } +func (p *ACParams) Truthy() bool { return true } + +// ACRequest is the Ruby wrapper over a *dispatch.Request — an ActionDispatch +// request over a Rack env, exposing the merged params, the path parameters and +// the format negotiation. It carries its class so classOf reports +// ActionDispatch::Request. +type ACRequest struct { + r *dispatch.Request + cls *RClass +} + +func (r *ACRequest) ToS() string { return "#" } +func (r *ACRequest) Inspect() string { return r.ToS() } +func (r *ACRequest) Truthy() bool { return true } + +// ACResponse is the Ruby wrapper over a *dispatch.Response — the mutable Rack +// response (status/headers/body/write). It carries its class so classOf reports +// ActionDispatch::Response. +type ACResponse struct { + r *dispatch.Response + cls *RClass +} + +func (r *ACResponse) ToS() string { return "#" } +func (r *ACResponse) Inspect() string { return r.ToS() } +func (r *ACResponse) Truthy() bool { return true } + +// registerActionPack installs the Action Pack surface (require +// "action_controller" / "action_dispatch"): the ActionDispatch module tree +// (Routing::RouteSet/Mapper, Request, Response), the ActionController module +// tree (Base, Metal, Parameters and the ParameterMissing/UnpermittedParameters/ +// UnfilteredParameters/DoubleRenderError/ActionNotFound errors) and the +// AbstractController::Base alias. The routing DSL, the controller dispatch +// pipeline, strong parameters and the request/response surfaces are wired in the +// sibling files. +func (vm *VM) registerActionPack() { + std := vm.consts["StandardError"].(*RClass) + + // --- ActionDispatch ----------------------------------------------------- + dispatchMod := newClass("ActionDispatch", nil) + dispatchMod.isModule = true + vm.consts["ActionDispatch"] = dispatchMod + + routingMod := newClass("ActionDispatch::Routing", nil) + routingMod.isModule = true + dispatchMod.consts["Routing"] = routingMod + vm.consts["ActionDispatch::Routing"] = routingMod + + routeSet := newClass("ActionDispatch::Routing::RouteSet", vm.cObject) + routingMod.consts["RouteSet"] = routeSet + vm.consts["ActionDispatch::Routing::RouteSet"] = routeSet + vm.cACRouteSet = routeSet + + mapper := newClass("ActionDispatch::Routing::Mapper", vm.cObject) + routingMod.consts["Mapper"] = mapper + vm.consts["ActionDispatch::Routing::Mapper"] = mapper + vm.cACMapper = mapper + + request := newClass("ActionDispatch::Request", vm.cObject) + dispatchMod.consts["Request"] = request + vm.consts["ActionDispatch::Request"] = request + vm.cACRequest = request + + response := newClass("ActionDispatch::Response", vm.cObject) + dispatchMod.consts["Response"] = response + vm.consts["ActionDispatch::Response"] = response + vm.cACResponse = response + + // --- AbstractController ------------------------------------------------- + abstractMod := newClass("AbstractController", nil) + abstractMod.isModule = true + vm.consts["AbstractController"] = abstractMod + // AbstractController::ActionNotFound / DoubleRenderError — the analogues the + // library returns, exposed as rescuable error classes. + acNotFound := newClass("AbstractController::ActionNotFound", std) + abstractMod.consts["ActionNotFound"] = acNotFound + vm.consts["AbstractController::ActionNotFound"] = acNotFound + doubleRender := newClass("AbstractController::DoubleRenderError", std) + abstractMod.consts["DoubleRenderError"] = doubleRender + vm.consts["AbstractController::DoubleRenderError"] = doubleRender + + // --- ActionController --------------------------------------------------- + controllerMod := newClass("ActionController", nil) + controllerMod.isModule = true + vm.consts["ActionController"] = controllerMod + + metal := newClass("ActionController::Metal", vm.cObject) + controllerMod.consts["Metal"] = metal + vm.consts["ActionController::Metal"] = metal + + base := newClass("ActionController::Base", metal) + controllerMod.consts["Base"] = base + vm.consts["ActionController::Base"] = base + vm.cACControllerBase = base + + // AbstractController::Base — the shared ancestor Rails exposes; alias it to + // ActionController::Metal so `AbstractController::Base` resolves. + abstractMod.consts["Base"] = metal + vm.consts["AbstractController::Base"] = metal + + params := newClass("ActionController::Parameters", vm.cObject) + controllerMod.consts["Parameters"] = params + vm.consts["ActionController::Parameters"] = params + vm.cACParameters = params + + // ActionController::ParameterMissing / UnpermittedParameters / + // UnfilteredParameters — the strong-parameters errors, rescuable by name. + for _, name := range []string{"ParameterMissing", "UnpermittedParameters", "UnfilteredParameters", "UrlGenerationError"} { + e := newClass("ActionController::"+name, std) + controllerMod.consts[name] = e + vm.consts["ActionController::"+name] = e + } + + vm.registerACRouteSet(routeSet, mapper) + vm.registerACParameters(params) + vm.registerACRequest(request) + vm.registerACResponse(response) + vm.registerACController(base) +} + +// apStr coerces an argument to its String contents (String or Symbol), else its +// to_s — the shared key/name coercion for the Action Pack DSLs. +func apStr(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + } + return v.ToS() +} + +// apInt coerces an argument to an int (for a status / redirect code), yielding 0 +// for a non-numeric value. +func apInt(v object.Value) int { + switch n := v.(type) { + case object.Integer: + return int(n) + case object.Float: + return int(n) + } + return 0 +} + +// apArg returns args[i] or nil when i is out of range. +func apArg(args []object.Value, i int) object.Value { + if i < len(args) { + return args[i] + } + return object.NilV +} + +// apStrList reads a String/Symbol or an Array of them into a []string — the +// shape used by via:, only: and except:. +func apStrList(v object.Value) []string { + if arr, ok := v.(*object.Array); ok { + out := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = apStr(e) + } + return out + } + return []string{apStr(v)} +} + +// apStrMap reads a Ruby Hash into a string→string map (stringifying keys and +// values), the shape constraints:/defaults: compile to; a non-Hash yields nil. +func apStrMap(v object.Value) map[string]string { + h, ok := v.(*object.Hash) + if !ok { + return nil + } + out := make(map[string]string, len(h.Keys)) + for _, k := range h.Keys { + val, _ := h.Get(k) + if rx, ok := val.(*Regexp); ok { + // A Ruby Regexp constraint (id: /\d+/) contributes its raw, unanchored + // source — the shape the route pattern compiler expects — not its + // inspect form (?-mix:…). + out[apStr(k)] = rx.source + continue + } + out[apStr(k)] = apStr(val) + } + return out +} diff --git a/internal/vm/actionpack_controller.go b/internal/vm/actionpack_controller.go new file mode 100644 index 00000000..f294c9e7 --- /dev/null +++ b/internal/vm/actionpack_controller.go @@ -0,0 +1,498 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + + "github.com/go-ruby-actionpack/actionpack/controller" + "github.com/go-ruby-actionpack/actionpack/dispatch" + rack "github.com/go-ruby-rack/rack" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires the ActionController::Base controller: the class-level DSL +// (before_action/after_action/around_action, rescue_from, view_context), the +// instance surface (render/redirect_to/head/params/request/response), and the +// PostsController.dispatch(action, env) entry point. A controller instance is a +// plain RObject of the user's subclass; its live *controller.Base is reached +// through the @__ac handle, so a bare `render` inside `def index` resolves to +// the inherited native method that writes into that request's response. Every +// seam — the action body (RunAction), the filter/rescue bodies, the view render +// — runs inline on the VM goroutine under the GVL. + +// acControllerDef is the per-ActionController::Base-subclass declaration the +// class DSL accumulates: the before/after/around filter specs, the rescue_from +// specs and the view-render context. Each subclass owns one; dispatch materialises +// it (and its ancestors') into a live *controller.Class bound to the instance. +type acControllerDef struct { + befores []acFilterSpec + afters []acFilterSpec + arounds []acFilterSpec + rescuers []acRescueSpec + viewContext object.Value // the ActionView-style render context (responds to #render), or nil +} + +// acFilterSpec is one before/after/around filter: a captured Ruby block or a +// symbol naming an instance method, with the only/except/if/unless conditions. +type acFilterSpec struct { + blk *Proc + sym string + only []string + except []string + ifCond acCond + unlessCond acCond +} + +// acCond is an if:/unless: condition — a Ruby block (lambda) or a symbol method +// name; set reports whether the condition was declared. +type acCond struct { + blk *Proc + sym string + set bool +} + +// acRescueSpec is one rescue_from declaration: the exception classes it matches, +// and the handler (a block or a with: method name). +type acRescueSpec struct { + classes []*RClass + blk *Proc + sym string +} + +// acRubyErr carries a Ruby exception raised by a controller body out to the +// library's rescue/dispatch layer and back into a Ruby rescue handler. +type acRubyErr struct{ e RubyError } + +func (e *acRubyErr) Error() string { return e.e.Error() } + +// acControllerDefFor returns the def for a controller subclass, creating it on +// first use (keyed by the class object, so each subclass keeps its own filters). +func (vm *VM) acControllerDefFor(cls *RClass) *acControllerDef { + if vm.acControllerDefs == nil { + vm.acControllerDefs = map[*RClass]*acControllerDef{} + } + d, ok := vm.acControllerDefs[cls] + if !ok { + d = &acControllerDef{} + vm.acControllerDefs[cls] = d + } + return d +} + +// acControllerChain returns cls's defs, outermost-ancestor first, so a subclass +// inherits its parents' filters/rescuers (parents run first, matching Rails). +func (vm *VM) acControllerChain(cls *RClass) []*acControllerDef { + var classes []*RClass + for c := cls; c != nil && c != vm.cACControllerBase.super; c = c.super { + classes = append(classes, c) + } + defs := make([]*acControllerDef, 0, len(classes)) + for i := len(classes) - 1; i >= 0; i-- { + if d, ok := vm.acControllerDefs[classes[i]]; ok { + defs = append(defs, d) + } + } + return defs +} + +// registerACController installs the ActionController::Base class DSL and the +// instance/dispatch surface (inherited by every subclass). +func (vm *VM) registerACController(base *RClass) { + // --- class-level DSL ---------------------------------------------------- + filterDSL := func(kind int) NativeFn { + return func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + d := vm.acControllerDefFor(self.(*RClass)) + specs := acFilterSpecs(args, blk) + switch kind { + case 0: + d.befores = append(d.befores, specs...) + case 1: + d.afters = append(d.afters, specs...) + default: + d.arounds = append(d.arounds, specs...) + } + return object.NilV + } + } + base.smethods["before_action"] = &Method{name: "before_action", owner: base, native: filterDSL(0)} + base.smethods["after_action"] = &Method{name: "after_action", owner: base, native: filterDSL(1)} + base.smethods["around_action"] = &Method{name: "around_action", owner: base, native: filterDSL(2)} + + // rescue_from Klass, ... [with: :meth] { |e| … } + base.smethods["rescue_from"] = &Method{name: "rescue_from", owner: base, native: func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + spec := acRescueSpec{blk: blk} + for _, a := range args { + if c, ok := a.(*RClass); ok { + spec.classes = append(spec.classes, c) + } + } + if h := lastHashOrNil(args); h != nil { + if v, ok := h.Get(object.Symbol("with")); ok { + spec.sym = apStr(v) + } + } + if len(spec.classes) == 0 { + raise("ArgumentError", "rescue_from requires at least one exception class") + } + d := vm.acControllerDefFor(self.(*RClass)) + d.rescuers = append(d.rescuers, spec) + return object.NilV + }} + + // view_context(obj) — set the ActionView-style render context (any object + // responding to #render). The Renderer seam sends it a `render` message. + base.smethods["view_context"] = &Method{name: "view_context", owner: base, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + vm.acControllerDefFor(self.(*RClass)).viewContext = apArg(args, 0) + return object.NilV + }} + + // PostsController.dispatch(action, env = nil) -> [status, headers, body]. + base.smethods["dispatch"] = &Method{name: "dispatch", owner: base, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.acDispatch(self.(*RClass), apStr(apArg(args, 0)), apArg(args, 1)) + }} + + // --- instance surface --------------------------------------------------- + base.define("render", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if err := acBaseOf(self).Render(acRenderOptions(args)); err != nil { + raise("AbstractController::DoubleRenderError", "%s", err.Error()) + } + return object.NilV + }) + base.define("redirect_to", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } + var status []int + if h := lastHashOrNil(args); h != nil { + if v, ok := h.Get(object.Symbol("status")); ok { + status = append(status, apInt(v)) + } + } + if err := acBaseOf(self).RedirectTo(rackStr(args[0]), status...); err != nil { + raise("AbstractController::DoubleRenderError", "%s", err.Error()) + } + return object.NilV + }) + base.define("head", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if err := acBaseOf(self).Head(apInt(apArg(args, 0))); err != nil { + raise("AbstractController::DoubleRenderError", "%s", err.Error()) + } + return object.NilV + }) + base.define("params", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACParams{p: acBaseOf(self).Params(), cls: vm.cACParameters} + }) + base.define("request", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACRequest{r: acBaseOf(self).Request(), cls: vm.cACRequest} + }) + base.define("response", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACResponse{r: acBaseOf(self).Response(), cls: vm.cACResponse} + }) + base.define("action_name", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(acBaseOf(self).Action()) + }) + base.define("performed?", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(acBaseOf(self).Performed()) + }) +} + +// acBaseOf returns the live *controller.Base backing a controller instance (from +// its @__ac handle, set by dispatch before the action runs). +func acBaseOf(self object.Value) *controller.Base { + return getIvar(self, "@__ac").(acHandle).v.(*controller.Base) +} + +// acDispatch materialises the controller class for one request, runs the action +// through the library pipeline and returns the SPEC [status, headers, body] +// Rack triple. An unrescued error is re-raised into Ruby. +func (vm *VM) acDispatch(cls *RClass, action string, envArg object.Value) object.Value { + inst := &RObject{class: cls, ivars: map[string]object.Value{}} + req := dispatch.NewRequest(acEnv(envArg)) + req.SetPathParameters(map[string]any{"controller": cls.name, "action": action}) + params, err := req.Parameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + cl := vm.acBuildController(inst, cls) + b := cl.New(req, dispatch.NewResponse(), params) + setIvar(inst, "@__ac", acHandle{b}) + if perr := b.Process(action); perr != nil { + panic(vm.acRubyErrorOf(perr)) + } + r := b.Response() + return object.NewArray(object.IntValue(int64(r.Status())), rackHeadersToHash(r.Headers()), rackBodyArray(r.Body())) +} + +// acEnv reads dispatch's optional env argument into a rack.Env: a nil argument +// yields an empty env, any Hash is converted as the Rack bindings do. +func acEnv(v object.Value) rack.Env { + if object.IsNil(v) { + return rack.Env{} + } + return rackEnv(v) +} + +// acBuildController assembles a *controller.Class from cls's def chain, every +// seam bound to inst: before/after/around filters, rescue_from handlers, the +// RunAction body (the Ruby action method) and the Renderer (the view context). +func (vm *VM) acBuildController(inst object.Value, cls *RClass) *controller.Class { + cl := controller.NewClass(cls.name) + var view object.Value + for _, d := range vm.acControllerChain(cls) { + for _, s := range d.befores { + spec := s + cl.BeforeAction(func(_ *controller.Base) error { return vm.acInvoke(inst, spec.blk, spec.sym) }, vm.acFilterOpts(inst, spec)...) + } + for _, s := range d.afters { + spec := s + cl.AfterAction(func(_ *controller.Base) error { return vm.acInvoke(inst, spec.blk, spec.sym) }, vm.acFilterOpts(inst, spec)...) + } + for _, s := range d.arounds { + spec := s + cl.AroundAction(func(_ *controller.Base, yield func() error) error { return vm.acAround(inst, spec, yield) }, vm.acFilterOpts(inst, spec)...) + } + for _, r := range d.rescuers { + spec := r + cl.RescueFrom(vm.acRescueMatch(spec), vm.acRescueHandle(inst, spec)) + } + if !object.IsNil(d.viewContext) { + view = d.viewContext + } + } + cl.RunAction(func(c *controller.Base, action string) error { + if vm.findMethod(inst, action) == nil { + return &controller.ActionNotFound{Controller: c.Class().Name, Action: action} + } + return vm.acInvoke(inst, nil, action) + }) + if !object.IsNil(view) { + cl.SetRenderer(vm.acRenderer(inst, view)) + } + return cl +} + +// acInvoke runs a controller body against inst — a captured block or a named +// instance method — converting a Ruby raise into a Go error the pipeline routes +// through rescue_from. +func (vm *VM) acInvoke(inst object.Value, blk *Proc, sym string) (err error) { + defer acRecover(&err) + if blk != nil { + vm.callBlockSelf(blk, inst, nil) + } else { + vm.send(inst, sym, nil, nil) + } + return nil +} + +// acRecover converts a recovered Ruby exception into an *acRubyErr stored in +// *err; a non-Ruby panic is re-raised untouched. +func acRecover(err *error) { + if r := recover(); r != nil { + if re, ok := r.(RubyError); ok { + *err = &acRubyErr{e: re} + return + } + panic(r) + } +} + +// acAround runs an around filter method, passing it a native yield block: when +// the Ruby method yields, the rest of the chain (and the action) runs, and any +// error it produces is re-raised at the yield point so the method may rescue it. +func (vm *VM) acAround(inst object.Value, spec acFilterSpec, yield func() error) (err error) { + defer acRecover(&err) + yp := &Proc{native: func(_ *VM, _ []object.Value) object.Value { + if e := yield(); e != nil { + panic(vm.acRubyErrorOf(e)) + } + return object.NilV + }} + vm.send(inst, spec.sym, nil, yp) + return nil +} + +// acFilterOpts maps a filter spec's only/except/if/unless conditions into the +// library's []controller.FilterOption. +func (vm *VM) acFilterOpts(inst object.Value, spec acFilterSpec) []controller.FilterOption { + var opts []controller.FilterOption + if len(spec.only) > 0 { + opts = append(opts, controller.Only(spec.only...)) + } + if len(spec.except) > 0 { + opts = append(opts, controller.Except(spec.except...)) + } + if spec.ifCond.set { + opts = append(opts, controller.If(vm.acCondFunc(inst, spec.ifCond))) + } + if spec.unlessCond.set { + opts = append(opts, controller.Unless(vm.acCondFunc(inst, spec.unlessCond))) + } + return opts +} + +// acCondFunc adapts an if:/unless: condition into a func(*Base) bool: a block +// runs against inst, a symbol sends the named predicate method. +func (vm *VM) acCondFunc(inst object.Value, cond acCond) func(*controller.Base) bool { + return func(_ *controller.Base) bool { + if cond.blk != nil { + return vm.callBlockSelf(cond.blk, inst, nil).Truthy() + } + return vm.send(inst, cond.sym, nil, nil).Truthy() + } +} + +// acRescueMatch adapts a rescue_from spec into the library's error matcher: the +// raised exception's Ruby class is compared against the declared classes. +func (vm *VM) acRescueMatch(spec acRescueSpec) func(error) bool { + return func(err error) bool { + ec := vm.classOf(vm.acErrValue(err)) + for _, c := range spec.classes { + if classIsA(ec, c) { + return true + } + } + return false + } +} + +// acRescueHandle adapts a rescue_from spec into the library's handler: it runs +// the block or sends the with: method against inst with the exception object. +func (vm *VM) acRescueHandle(inst object.Value, spec acRescueSpec) func(*controller.Base, error) error { + return func(_ *controller.Base, err error) error { + obj := vm.acErrValue(err) + if spec.blk != nil { + vm.callBlockSelf(spec.blk, inst, []object.Value{obj}) + } else { + vm.send(inst, spec.sym, []object.Value{obj}, nil) + } + return nil + } +} + +// acRenderer adapts a view context into the library's Renderer seam: it sends a +// `render` message with the render options as a Ruby Hash and returns the body. +func (vm *VM) acRenderer(inst object.Value, view object.Value) controller.Renderer { + return func(_ *controller.Base, opts controller.RenderOptions) (string, error) { + r := vm.send(view, "render", []object.Value{acRenderOptsHash(opts)}, nil) + if s, ok := r.(*object.String); ok { + return s.Str(), nil + } + return r.ToS(), nil + } +} + +// acErrValue maps a pipeline error back into the Ruby exception object a rescue +// handler receives. +func (vm *VM) acErrValue(err error) object.Value { + return vm.exceptionObject(vm.acRubyErrorOf(err)) +} + +// acRubyErrorOf converts a pipeline error into the RubyError to raise: a wrapped +// Ruby exception carries its original object; the only other error the pipeline +// yields is an unmapped action, reported as AbstractController::ActionNotFound. +func (vm *VM) acRubyErrorOf(err error) RubyError { + var re *acRubyErr + if errors.As(err, &re) { + return re.e + } + return RubyError{Class: "AbstractController::ActionNotFound", Message: err.Error()} +} + +// acFilterSpecs turns a before/after/around DSL call's arguments (symbol method +// names + a trailing options Hash) and optional block into filter specs: one per +// symbol plus one for the block, all sharing the only/except/if/unless options. +func acFilterSpecs(args []object.Value, blk *Proc) []acFilterSpec { + var only, except []string + var ifCond, unlessCond acCond + if h := lastHashOrNil(args); h != nil { + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "only": + only = apStrList(val) + case "except": + except = apStrList(val) + case "if": + ifCond = acCondFrom(val) + case "unless": + unlessCond = acCondFrom(val) + } + } + } + mk := func(blk *Proc, sym string) acFilterSpec { + return acFilterSpec{blk: blk, sym: sym, only: only, except: except, ifCond: ifCond, unlessCond: unlessCond} + } + var specs []acFilterSpec + for _, a := range args { + if _, ok := a.(*object.Hash); ok { + continue + } + specs = append(specs, mk(nil, apStr(a))) + } + if blk != nil { + specs = append(specs, mk(blk, "")) + } + return specs +} + +// acCondFrom reads an if:/unless: value into an acCond: a Proc (lambda) is a +// block condition, anything else a symbol predicate-method name. +func acCondFrom(v object.Value) acCond { + if p, ok := v.(*Proc); ok { + return acCond{blk: p, set: true} + } + return acCond{sym: apStr(v), set: true} +} + +// acRenderOptions builds a controller.RenderOptions from a render call: a leading +// String is the template/action name; the options Hash supplies plain/json/ +// template/action/status/content_type/layout. +func acRenderOptions(args []object.Value) controller.RenderOptions { + var o controller.RenderOptions + if len(args) > 0 { + if s, ok := args[0].(*object.String); ok { + o.Template = s.Str() + } + } + if h := lastHashOrNil(args); h != nil { + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "plain": + o.Plain = rackStr(val) + case "json": + o.JSON = rackToGo(val) + case "template": + o.Template = apStr(val) + case "action": + o.Action = apStr(val) + case "status": + o.Status = apInt(val) + case "content_type": + o.ContentType = rackStr(val) + case "layout": + o.Layout = apStr(val) + } + } + } + return o +} + +// acRenderOptsHash maps a controller.RenderOptions into the Ruby Hash a view +// context's #render receives (symbol keys), so the ActionView seam sees the +// full render request. +func acRenderOptsHash(opts controller.RenderOptions) *object.Hash { + h := object.NewHash() + h.Set(object.Symbol("template"), object.NewString(opts.Template)) + h.Set(object.Symbol("action"), object.NewString(opts.Action)) + h.Set(object.Symbol("plain"), object.NewString(opts.Plain)) + h.Set(object.Symbol("json"), rackFromGo(opts.JSON)) + h.Set(object.Symbol("status"), object.IntValue(int64(opts.Status))) + h.Set(object.Symbol("content_type"), object.NewString(opts.ContentType)) + h.Set(object.Symbol("layout"), object.NewString(opts.Layout)) + return h +} diff --git a/internal/vm/actionpack_dispatch.go b/internal/vm/actionpack_dispatch.go new file mode 100644 index 00000000..79f48144 --- /dev/null +++ b/internal/vm/actionpack_dispatch.go @@ -0,0 +1,125 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/dispatch" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires ActionDispatch::Request (over a Rack env: merged params, path +// parameters, format negotiation) and ActionDispatch::Response (the mutable Rack +// response). The env parsing, param merging and format negotiation are the +// library's; this shell maps values in and out. + +// registerACRequest installs the ActionDispatch::Request surface. +func (vm *VM) registerACRequest(cls *RClass) { + self := func(v object.Value) *dispatch.Request { return v.(*ACRequest).r } + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return &ACRequest{r: dispatch.NewRequest(rackEnv(rackArg(args))), cls: cls} + }} + + // params — the merged strong-parameters view as ActionController::Parameters. + cls.define("params", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + p, err := self(v).Parameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return &ACParams{p: p, cls: vm.cACParameters} + }) + + cls.define("query_parameters", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m, err := self(v).QueryParameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return rackFromGo(m) + }) + cls.define("request_parameters", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m, err := self(v).RequestParameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return rackFromGo(m) + }) + cls.define("path_parameters", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackFromGo(self(v).PathParameters()) + }) + cls.define("set_path_parameters", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetPathParameters(acAnyMap(lastHashOrNil(args))) + return object.NilV + }) + cls.define("controller_name", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).ControllerName()) + }) + cls.define("action_name", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).ActionName()) + }) + cls.define("format", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).Format()) + }) + cls.define("request_method", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).RequestMethod()) + }) + cls.define("path", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).PathInfo()) + }) +} + +// registerACResponse installs the ActionDispatch::Response surface. +func (vm *VM) registerACResponse(cls *RClass) { + self := func(v object.Value) *dispatch.Response { return v.(*ACResponse).r } + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACResponse{r: dispatch.NewResponse(), cls: cls} + }} + + cls.define("status", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).Status())) + }) + cls.define("status=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetStatus(apInt(apArg(args, 0))) + return apArg(args, 0) + }) + cls.define("write", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + for _, a := range args { + self(v).Write(rackStr(a)) + } + return object.NilV + }) + cls.define("body", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).BodyString()) + }) + cls.define("headers", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackHeadersToHash(self(v).Headers()) + }) + cls.define("[]", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + return rackFromGo(self(v).Headers().Get(apStr(apArg(args, 0)))) + }) + cls.define("[]=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + self(v).Headers().Set(apStr(args[0]), rackStr(args[1])) + return args[1] + }) + cls.define("content_type=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetContentType(rackStr(apArg(args, 0))) + return apArg(args, 0) + }) + cls.define("redirect", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + status := 302 + if len(args) > 1 { + status = apInt(args[1]) + } + self(v).Redirect(rackStr(args[0]), status) + return object.NilV + }) +} diff --git a/internal/vm/actionpack_internal_test.go b/internal/vm/actionpack_internal_test.go new file mode 100644 index 00000000..6857516c --- /dev/null +++ b/internal/vm/actionpack_internal_test.go @@ -0,0 +1,701 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "testing" + + "github.com/go-ruby-actionpack/actionpack/controller" + "github.com/go-ruby-actionpack/actionpack/dispatch" + "github.com/go-ruby-actionpack/actionpack/parameters" + rack "github.com/go-ruby-rack/rack" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// TestActionPackValueInspect covers the value wrappers' string/truthy surface. +func TestActionPackValueInspect(t *testing.T) { + rs := &ACRouteSet{} + mp := &ACMapper{} + rq := &ACRequest{} + rp := &ACResponse{} + for _, c := range []struct { + v interface{ ToS() string } + want string + }{ + {rs, "#"}, + {mp, "#"}, + {rq, "#"}, + {rp, "#"}, + } { + if c.v.ToS() != c.want { + t.Errorf("ToS = %q, want %q", c.v.ToS(), c.want) + } + } + if !rs.Truthy() || !mp.Truthy() || !rq.Truthy() || !rp.Truthy() { + t.Error("Truthy should be true") + } + if rs.Inspect() != rs.ToS() || mp.Inspect() != mp.ToS() || rq.Inspect() != rq.ToS() || rp.Inspect() != rp.ToS() { + t.Error("Inspect should equal ToS") + } + // ACParams delegates ToS/Inspect to the library's String(). + pw := &ACParams{p: parameters.New(map[string]any{"a": int64(1)})} + if pw.ToS() != pw.Inspect() || pw.ToS() == "" || !pw.Truthy() { + t.Errorf("ACParams ToS/Inspect/Truthy: %q", pw.ToS()) + } +} + +// TestActionPackHelpers covers the standalone coercion helpers' every branch. +func TestActionPackHelpers(t *testing.T) { + if apStr(object.NewString("s")) != "s" || apStr(object.Symbol("y")) != "y" || apStr(object.IntValue(3)) != "3" { + t.Error("apStr") + } + if apInt(object.IntValue(5)) != 5 || apInt(object.Float(2.9)) != 2 || apInt(object.NewString("x")) != 0 { + t.Error("apInt") + } + if apArg([]object.Value{object.IntValue(1)}, 0) != object.IntValue(1) || apArg(nil, 2) != object.NilV { + t.Error("apArg") + } + if l := apStrList(object.NewArray(object.Symbol("a"), object.NewString("b"))); len(l) != 2 || l[0] != "a" || l[1] != "b" { + t.Error("apStrList array") + } + if l := apStrList(object.Symbol("only")); len(l) != 1 || l[0] != "only" { + t.Error("apStrList single") + } + if apStrMap(object.NilV) != nil { + t.Error("apStrMap non-hash") + } + h := object.NewHash() + h.Set(object.Symbol("id"), object.NewString(`\d+`)) + if m := apStrMap(h); m["id"] != `\d+` { + t.Error("apStrMap hash") + } + if lastHashOrNil([]object.Value{object.IntValue(1)}) != nil { + t.Error("lastHashOrNil non-hash") + } + // acErrValue reuses acRubyErrorOf; the ActionNotFound branch is covered here. + vm := New(nil) + nf := &controller.ActionNotFound{Controller: "c", Action: "a"} + if got := vm.acRubyErrorOf(nf); got.Class != "AbstractController::ActionNotFound" { + t.Errorf("acRubyErrorOf(ActionNotFound) class = %q", got.Class) + } + if obj := vm.acErrValue(nf); vm.classOf(obj).name != "AbstractController::ActionNotFound" { + t.Errorf("acErrValue(ActionNotFound) class = %q", vm.classOf(obj).name) + } + if (&acRubyErr{e: RubyError{Class: "X", Message: "y"}}).Error() != "X: y" { + t.Error("acRubyErr.Error") + } +} + +// --- routing --------------------------------------------------------------- + +func TestActionPackRoutingDraw(t *testing.T) { + src := ` +routes = ActionDispatch::Routing::RouteSet.new +routes.draw do + get "posts", to: "posts#index", as: "posts" + get "posts/:id", to: "posts#show", as: "post" + post "posts", to: "posts#create" + put "posts/:id", to: "posts#update" + patch "posts/:id", to: "posts#patch_it" + delete "posts/:id", to: "posts#destroy" + match "ping", to: "meta#ping", via: [:get, :head] + root to: "home#index" +end +puts routes.class +r = routes.recognize("GET", "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/posts/7") +puts r["controller"] +puts r["action"] +puts r["id"] +puts routes.recognize("GET", "/nope").inspect +puts routes.path("post", id: 3) +puts routes.path_args("post", 9) +puts routes.url_for(controller: "posts", action: "index") +puts routes.routes.length +puts routes.routes[0]["verb"] +` + got := acRun(t, src) + want := "ActionDispatch::Routing::RouteSet\nposts\nshow\n7\nnil\n/posts/3\n/posts/9\n/posts\n9\nGET" + if got != want { + t.Errorf("routing draw:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackRoutingResources(t *testing.T) { + src := ` +routes = ActionDispatch::Routing::RouteSet.new +routes.draw do + resources :posts, only: [:index, :show] do + member { get "preview", to: "posts#preview" } + collection { get "search", to: "posts#search" } + end + resources :photos, except: [:destroy] + resource :profile + resources :legacy, path: "old", controller: "legacy_ctrl", param: "key" + namespace :admin do + resources :users + end + scope path: "api", module: "api", as: "api" do + get "status", to: "status#show", as: "status" + end + constraints id: /\d+/ do + get "num/:id", to: "num#show" + end +end +puts routes.recognize("GET", "/posts")["action"] +puts routes.recognize("GET", "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/posts/1")["action"] +puts routes.recognize("GET", "/posts/1/preview")["action"] +puts routes.recognize("GET", "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/posts/search")["action"] +puts routes.recognize("GET", "/profile")["action"] +puts routes.recognize("GET", "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/admin/users")["controller"] +puts routes.recognize("GET", "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/api/status")["controller"] +puts routes.recognize("GET", "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/num/12")["action"] +` + got := acRun(t, src) + want := "index\nshow\npreview\nsearch\nshow\nadmin/users\napi/status\nshow" + if got != want { + t.Errorf("routing resources:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackRoutingErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {`ActionDispatch::Routing::RouteSet.new.draw`, "ArgumentError"}, + {`ActionDispatch::Routing::RouteSet.new.draw { get "x(", to: "a#b" }`, "ArgumentError"}, + {`ActionDispatch::Routing::RouteSet.new.path("nope")`, "ActionController::UrlGenerationError"}, + {`r=ActionDispatch::Routing::RouteSet.new; r.draw { get "p/:id", to: "a#b", as: "p" }; r.path("p")`, "ActionController::UrlGenerationError"}, + {`ActionDispatch::Routing::RouteSet.new.path_args("nope")`, "ActionController::UrlGenerationError"}, + {`r=ActionDispatch::Routing::RouteSet.new; r.draw { get "p/:id", to: "a#b", as: "p" }; r.path_args("p", 1, 2)`, "ActionController::UrlGenerationError"}, + {`ActionDispatch::Routing::RouteSet.new.path_args`, "ArgumentError"}, + {`ActionDispatch::Routing::RouteSet.new.url_for(controller: "x", action: "y")`, "ActionController::UrlGenerationError"}, + {`ActionDispatch::Routing::RouteSet.new.draw { namespace(:a) }`, "ArgumentError"}, + } + for _, c := range cases { + if got := acRunErr(t, c.src); got != c.want { + t.Errorf("err(%q) = %q, want %q", c.src, got, c.want) + } + } +} + +func TestActionPackRoutingDefaultsAndScopeExtras(t *testing.T) { + // defaults:, constraints:, controller:/action:, on:, scope defaults/constraints/as. + src := ` +routes = ActionDispatch::Routing::RouteSet.new +routes.draw do + get "widgets", controller: "widgets", action: "index", defaults: { fmt: "json" } + get "items/:id", to: "items#show", constraints: { id: "[0-9]+" } + scope path: "s", as: "s", constraints: { x: "1" }, defaults: { d: "2" } do + get "inner", to: "inner#go" + end +end +r = routes.recognize("GET", "/widgets") +puts r["controller"] +puts r["fmt"] +puts routes.recognize("GET", "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/items/5")["action"] +puts routes.recognize("GET", "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/s/inner")["controller"] +` + got := acRun(t, src) + want := "widgets\njson\nshow\ninner" + if got != want { + t.Errorf("routing defaults:\n got=%q\nwant=%q", got, want) + } +} + +// --- parameters ------------------------------------------------------------ + +func TestActionPackParameters(t *testing.T) { + src := ` +p = ActionController::Parameters.new(name: "Bob", age: 30, tags: ["a","b"], user: { email: "e", role: "r" }) +puts p.class +puts p.permitted? +puts p.key?(:name) +puts p.has_key?(:missing) +puts p.keys.sort.join(",") +permitted = p.permit(:name, tags: [], user: [:email]) +puts permitted.permitted? +puts permitted[:name] +puts permitted[:missing].inspect +puts permitted.to_h["name"] +puts p.to_unsafe_h["age"] +puts p.require(:name) +nested = p.require(:user) +puts nested.class +puts nested[:email] +vals = p.require([:name, :age]) +puts vals.join("/") +merged = p.merge(ActionController::Parameters.new(name: "Alice")) +puts merged[:name] +` + got := acRun(t, src) + want := "ActionController::Parameters\nfalse\ntrue\nfalse\nage,name,tags,user\ntrue\nBob\nnil\nBob\n30\nBob\nActionController::Parameters\ne\nBob/30\nAlice" + if got != want { + t.Errorf("parameters:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackParametersErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {`ActionController::Parameters.new(a: 1).require(:missing)`, "ActionController::ParameterMissing"}, + {`ActionController::Parameters.new(a: 1).require([:a, :missing])`, "ActionController::ParameterMissing"}, + {`ActionController::Parameters.new(a: 1).to_h`, "ActionController::UnfilteredParameters"}, + {`ActionController::Parameters.new(a: 1).merge(42)`, "TypeError"}, + } + for _, c := range cases { + if got := acRunErr(t, c.src); got != c.want { + t.Errorf("err(%q) = %q, want %q", c.src, got, c.want) + } + } +} + +// --- dispatch (Request/Response) ------------------------------------------- + +func TestActionPackDispatchRequest(t *testing.T) { + src := ` +req = ActionDispatch::Request.new("REQUEST_METHOD" => "GET", "PATH_INFO" => "/posts", "QUERY_STRING" => "id=5&q=hi") +req.set_path_parameters(controller: "posts", action: "show") +puts req.class +puts req.request_method +puts req.path +puts req.controller_name +puts req.action_name +puts req.format +puts req.params[:id] +puts req.query_parameters["q"] +puts req.request_parameters.length +puts req.path_parameters["action"] +` + got := acRun(t, src) + want := "ActionDispatch::Request\nGET\n/posts\nposts\nshow\nhtml\n5\nhi\n0\nshow" + if got != want { + t.Errorf("dispatch request:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackDispatchRequestErrors(t *testing.T) { + bad := `ActionDispatch::Request.new("QUERY_STRING" => "a=%zz")` + for _, m := range []string{".params", ".query_parameters"} { + if got := acRunErr(t, bad+m); got != "ArgumentError" { + t.Errorf("err(%s) = %q, want ArgumentError", m, got) + } + } +} + +// TestActionPackRequestParamsError covers the request_parameters (POST body) +// parse-error branch, which needs a real rack.Input the Ruby env cannot express. +func TestActionPackRequestParamsError(t *testing.T) { + vm := New(nil) + env := rack.Env{ + "REQUEST_METHOD": "POST", + "CONTENT_TYPE": "application/x-www-form-urlencoded", + "rack.input": &acBadInput{data: []byte("a=%zz")}, + } + req := &ACRequest{r: dispatch.NewRequest(env), cls: vm.cACRequest} + var class string + func() { + defer func() { + if r := recover(); r != nil { + if re, ok := r.(RubyError); ok { + class = re.Class + } + } + }() + vm.send(req, "request_parameters", nil, nil) + }() + if class != "ArgumentError" { + t.Errorf("request_parameters bad body raised %q, want ArgumentError", class) + } +} + +// acBadInput is a one-shot rack.Input returning a malformed urlencoded body. +type acBadInput struct { + data []byte + done bool +} + +func (b *acBadInput) Read(int) ([]byte, error) { + if b.done { + return nil, nil + } + b.done = true + return b.data, nil +} + +func TestActionPackDispatchResponse(t *testing.T) { + src := ` +resp = ActionDispatch::Response.new +puts resp.class +puts resp.status +resp.status = 201 +resp.write("hello ") +resp.write("world") +puts resp.status +puts resp.body +resp["X-Test"] = "yes" +resp.content_type = "text/plain" +puts resp["X-Test"] +puts resp.headers["content-type"] +resp2 = ActionDispatch::Response.new +resp2.redirect("/here", 303) +puts resp2.status +puts resp2.headers["location"] +` + got := acRun(t, src) + want := "ActionDispatch::Response\n200\n201\nhello world\nyes\ntext/plain\n303\n/here" + if got != want { + t.Errorf("dispatch response:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackDispatchResponseErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {`r=ActionDispatch::Response.new; r["only-key"]=nil; r.send(:[]=, "k")`, "ArgumentError"}, + {`ActionDispatch::Response.new.redirect`, "ArgumentError"}, + } + for _, c := range cases { + if got := acRunErr(t, c.src); got != c.want { + t.Errorf("err(%q) = %q, want %q", c.src, got, c.want) + } + } +} + +// --- controller ------------------------------------------------------------ + +func TestActionPackControllerBasic(t *testing.T) { + src := ` +class PostsController < ActionController::Base + def index + render plain: "all posts" + end + def show + render plain: "post #{params[:id]}", status: 200, content_type: "text/plain" + end + def by_name + render "posts/named" + end + def created + render json: { ok: true }, status: 201, layout: "app", action: "created" + end + def go_away + redirect_to "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/login" + end + def go_perm + redirect_to "/new", status: 301 + end + def gone + head 410 + end + def whoami + render plain: "#{action_name}/#{performed?}/#{request.request_method}/#{response.status}" + end +end +puts PostsController.dispatch(:index)[2][0] +puts PostsController.dispatch(:show, "QUERY_STRING" => "id=7")[2][0] +puts PostsController.dispatch(:go_away)[0] +puts PostsController.dispatch(:go_away, {})[1]["location"] +puts PostsController.dispatch(:go_perm)[0] +puts PostsController.dispatch(:gone)[0] +puts PostsController.dispatch(:whoami, "REQUEST_METHOD" => "POST")[2][0] +puts PostsController.dispatch(:created)[0] +puts PostsController.dispatch(:by_name)[0] +` + got := acRun(t, src) + want := "all posts\npost 7\n302\n/login\n301\n410\nwhoami/false/POST/200\n201\n200" + if got != want { + t.Errorf("controller basic:\n got=%q\nwant=%q", got, want) + } +} + +// TestActionPackControllerEdgeCases covers the remaining seam branches: an +// around filter whose inner action raises (re-raised at the yield point), a +// non-Ruby panic (throw) surfacing through the body recover, and a dispatch +// whose request params fail to parse. +func TestActionPackControllerEdgeCases(t *testing.T) { + aroundRaise := ` +class AroundRaiseController < ActionController::Base + around_action :wrap + def wrap; yield; end + def boom; raise "inner"; end +end +AroundRaiseController.dispatch(:boom) +` + if got := acRunErr(t, aroundRaise); got != "RuntimeError" { + t.Errorf("around raise = %q, want RuntimeError", got) + } + + throwSrc := ` +class ThrowController < ActionController::Base + def t; throw :nope; end +end +ThrowController.dispatch(:t) +` + if got := acRunErr(t, throwSrc); got == "" { + t.Error("throw should surface an uncaught error") + } + + badQuery := ` +class QController < ActionController::Base + def index; render plain: "x"; end +end +QController.dispatch(:index, "QUERY_STRING" => "a=%zz") +` + if got := acRunErr(t, badQuery); got != "ArgumentError" { + t.Errorf("dispatch bad query = %q, want ArgumentError", got) + } +} + +// TestActionPackRoutingExtras covers the routing-helper branches not hit by the +// main DSL tests: a verb with no options, `on:`, a positional/empty root target, +// a bare scope, and path_args with a trailing options Hash. +func TestActionPackRoutingExtras(t *testing.T) { + src := ` +r1 = ActionDispatch::Routing::RouteSet.new +r1.draw do + get "healthz" + get to: "home#dashboard" + resources :things do + get "activate", to: "things#activate", on: :member + end + root "welcome#index" + scope do + get "bare", to: "bare#go" + end +end +puts r1.recognize("GET", "/things/1/activate")["action"] +puts r1.recognize("GET", "/")["controller"] +puts r1.recognize("GET", "/bare")["controller"] +puts r1.path_args("thing", 5, format: "json") + +r2 = ActionDispatch::Routing::RouteSet.new +r2.draw { root } +puts r2.recognize("GET", "/")["controller"].inspect +` + got := acRun(t, src) + want := "activate\nhome\nbare\n/things/5.json\n\"\"" + if got != want { + t.Errorf("routing extras:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerFilters(t *testing.T) { + src := ` +class AppController < ActionController::Base + before_action :log_start + def log_start + @trace = "start" + end +end +class GuardedController < AppController + before_action :require_login, only: [:secret] + before_action { @trace = "#{@trace}|blk" } + after_action :log_end, except: [:secret] + around_action :timing + before_action :block_it, if: :blocked?, unless: -> { false } + + def open + render plain: "#{@trace}|open|#{@timing}" + end + def secret + render plain: "secret" + end + def blocked + render plain: "should not reach" + end + + def require_login + render plain: "denied", status: 403 + end + def log_end + @trace = "#{@trace}|end" + end + def timing + @timing = "T0" + yield + @timing = "T1" + end + def blocked? + action_name == "blocked" + end + def block_it + render plain: "blocked!", status: 400 + end +end +puts GuardedController.dispatch(:open)[2][0] +puts GuardedController.dispatch(:secret)[0] +puts GuardedController.dispatch(:secret)[2][0] +puts GuardedController.dispatch(:blocked)[0] +puts GuardedController.dispatch(:blocked)[2][0] +` + got := acRun(t, src) + // open: before log_start ("start") + block ("|blk"), around sets @timing T0 before + // action runs (render reads it), timing filter runs, no login guard (only :secret), + // no block_it (blocked? false for "open"). + want := "start|blk|open|T0\n403\ndenied\n400\nblocked!" + if got != want { + t.Errorf("controller filters:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerRescue(t *testing.T) { + src := ` +class RescuingController < ActionController::Base + rescue_from ArgumentError do |e| + render plain: "rescued: #{e.message}", status: 422 + end + rescue_from RuntimeError, with: :handle_runtime + rescue_from AbstractController::ActionNotFound do |e| + render plain: "no action", status: 404 + end + + def boom + raise ArgumentError, "bad arg" + end + def kaboom + raise "runtime issue" + end + def handle_runtime(e) + render plain: "runtime: #{e.message}", status: 500 + end +end +puts RescuingController.dispatch(:boom)[0] +puts RescuingController.dispatch(:boom)[2][0] +puts RescuingController.dispatch(:kaboom)[2][0] +puts RescuingController.dispatch(:no_such_action)[0] +puts RescuingController.dispatch(:no_such_action)[2][0] +` + got := acRun(t, src) + want := "422\nrescued: bad arg\nruntime: runtime issue\n404\nno action" + if got != want { + t.Errorf("controller rescue:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerRescueMiss(t *testing.T) { + // rescue_from that does not match the raised error -> propagates to Ruby. + src := ` +class NarrowController < ActionController::Base + rescue_from ArgumentError do |e| render plain: "nope" end + def boom; raise "not an ArgumentError"; end +end +NarrowController.dispatch(:boom) +` + if got := acRunErr(t, src); got != "RuntimeError" { + t.Errorf("rescue miss = %q, want RuntimeError", got) + } + // A truly unmapped action with no rescue -> AbstractController::ActionNotFound. + src2 := ` +class BareController < ActionController::Base; end +BareController.dispatch(:ghost) +` + if got := acRunErr(t, src2); got != "AbstractController::ActionNotFound" { + t.Errorf("bare missing action = %q, want AbstractController::ActionNotFound", got) + } +} + +func TestActionPackControllerViewContext(t *testing.T) { + src := ` +class StringView + def render(opts) + "view[#{opts[:template]}/#{opts[:action]}/#{opts[:plain]}/#{opts[:status]}/#{opts[:layout]}]" + end +end +class IntView + def render(opts); 4242; end +end +class ViewedController < ActionController::Base + view_context StringView.new + def show; render template: "posts/show", status: 200, layout: "main"; end +end +class IntViewedController < ActionController::Base + view_context IntView.new + def show; render plain: "ignored"; end +end +puts ViewedController.dispatch(:show)[2][0] +puts IntViewedController.dispatch(:show)[2][0] +` + got := acRun(t, src) + want := "view[posts/show///200/main]\n4242" + if got != want { + t.Errorf("controller view context:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerDoubleRender(t *testing.T) { + src := ` +class DoubleController < ActionController::Base + def twice + render plain: "one" + render plain: "two" + end + def render_then_redirect + render plain: "x" + redirect_to "/y" + end + def double_head + head 200 + head 201 + end +end +DoubleController.dispatch(:twice) +` + if got := acRunErr(t, src); got != "AbstractController::DoubleRenderError" { + t.Errorf("double render = %q", got) + } + for _, a := range []string{":render_then_redirect", ":double_head"} { + s := ` +class DoubleController < ActionController::Base + def render_then_redirect; render plain: "x"; redirect_to "/y"; end + def double_head; head 200; head 201; end +end +DoubleController.dispatch(` + a + `)` + if got := acRunErr(t, s); got != "AbstractController::DoubleRenderError" { + t.Errorf("double(%s) = %q", a, got) + } + } +} + +func TestActionPackControllerNoArgErrors(t *testing.T) { + src := ` +class ArgController < ActionController::Base + def bad; redirect_to; end +end +ArgController.dispatch(:bad) +` + if got := acRunErr(t, src); got != "ArgumentError" { + t.Errorf("redirect_to no arg = %q", got) + } +} + +func TestActionPackControllerRescueWithBlockException(t *testing.T) { + // A rescue_from whose exception has a plain message; covers acErrValue's + // acRubyErr path and the with:-method handler branch already; here we cover a + // controller whose action defines its own render context via inheritance. + src := ` +class BaseWithRescue < ActionController::Base + rescue_from StandardError, with: :on_error + def on_error(e); render plain: "handled #{e.class}"; end +end +class ChildController < BaseWithRescue + def boom; raise ArgumentError, "x"; end +end +puts ChildController.dispatch(:boom)[2][0] +` + got := acRun(t, src) + want := "handled ArgumentError" + if got != want { + t.Errorf("inherited rescue:\n got=%q\nwant=%q", got, want) + } +} + +// TestActionPackRescueFromArgError covers rescue_from with no class argument. +func TestActionPackRescueFromArgError(t *testing.T) { + src := ` +class NoClassController < ActionController::Base + rescue_from with: :x +end +` + if got := acRunErr(t, src); got != "ArgumentError" { + t.Errorf("rescue_from no class = %q, want ArgumentError", got) + } +} diff --git a/internal/vm/actionpack_params.go b/internal/vm/actionpack_params.go new file mode 100644 index 00000000..86662494 --- /dev/null +++ b/internal/vm/actionpack_params.go @@ -0,0 +1,132 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/parameters" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires ActionController::Parameters (Rails strong parameters): +// .new(hash), permit/require, [], to_h/to_unsafe_h, permitted?, key?, keys and +// merge. The permit/require filtering semantics are the library's; this shell +// only maps Ruby values in and out. + +// registerACParameters installs the ActionController::Parameters surface. +func (vm *VM) registerACParameters(cls *RClass) { + self := func(v object.Value) *parameters.Parameters { return v.(*ACParams).p } + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return &ACParams{p: parameters.New(acAnyMap(lastHashOrNil(args))), cls: cls} + }} + + // permit(*filters) — return a new, permitted Parameters filtered by the + // String/Array/Hash filter list. + cls.define("permit", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + return &ACParams{p: self(v).Permit(acPermitFilters(args)...), cls: vm.cACParameters} + }) + + // require(key) — return the value under key (raising ParameterMissing when + // absent/blank); require([:a, :b]) requires each in turn. + cls.define("require", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if arr, ok := apArg(args, 0).(*object.Array); ok { + keys := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + keys[i] = apStr(e) + } + vals, err := self(v).RequireAll(keys) + if err != nil { + raise("ActionController::ParameterMissing", "%s", err.Error()) + } + out := make([]object.Value, len(vals)) + for i, val := range vals { + out[i] = vm.acParamValue(val) + } + return object.NewArrayFromSlice(out) + } + val, err := self(v).Require(apStr(apArg(args, 0))) + if err != nil { + raise("ActionController::ParameterMissing", "%s", err.Error()) + } + return vm.acParamValue(val) + }) + + // [] — read the value under key (nil when absent), matching Rails' accessor. + cls.define("[]", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + val, ok := self(v).Get(apStr(apArg(args, 0))) + if !ok { + return object.NilV + } + return vm.acParamValue(val) + }) + + // to_h — a deep plain-Hash of the permitted params (raising when unpermitted). + cls.define("to_h", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m, err := self(v).ToH() + if err != nil { + raise("ActionController::UnfilteredParameters", "%s", err.Error()) + } + return rackFromGo(m) + }) + + // to_unsafe_h — a deep plain-Hash regardless of the permitted flag. + cls.define("to_unsafe_h", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackFromGo(self(v).ToUnsafeH()) + }) + + cls.define("permitted?", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).Permitted()) + }) + + keyP := func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).Has(apStr(apArg(args, 0)))) + } + cls.define("key?", keyP) + cls.define("has_key?", keyP) + + cls.define("keys", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + ks := self(v).Keys() + out := make([]object.Value, len(ks)) + for i, k := range ks { + out[i] = object.NewString(k) + } + return object.NewArrayFromSlice(out) + }) + + // merge(other) — a new Parameters overlaid by another Parameters' pairs. + cls.define("merge", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + other, ok := apArg(args, 0).(*ACParams) + if !ok { + raise("TypeError", "no implicit conversion into ActionController::Parameters") + } + return &ACParams{p: self(v).Merge(other.p), cls: vm.cACParameters} + }) +} + +// acParamValue maps a strong-parameters value (as returned by Get/Require) back +// into the rbgo object graph: a nested *parameters.Parameters becomes a wrapped +// ActionController::Parameters, everything else its rackFromGo form. +func (vm *VM) acParamValue(v any) object.Value { + if p, ok := v.(*parameters.Parameters); ok { + return &ACParams{p: p, cls: vm.cACParameters} + } + return rackFromGo(v) +} + +// acPermitFilters maps permit's arguments into the []any filter list the library +// consumes: a String/Symbol is a scalar key, a Hash (key => [] / [inner] / {}) +// is a nested/array filter mapped through rackToGo. +func acPermitFilters(args []object.Value) []any { + out := make([]any, 0, len(args)) + for _, a := range args { + if h, ok := a.(*object.Hash); ok { + out = append(out, acAnyMap(h)) + continue + } + out = append(out, apStr(a)) + } + return out +} diff --git a/internal/vm/actionpack_routing.go b/internal/vm/actionpack_routing.go new file mode 100644 index 00000000..a42cd39a --- /dev/null +++ b/internal/vm/actionpack_routing.go @@ -0,0 +1,339 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/routing" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires ActionDispatch::Routing::RouteSet.new, its `draw` DSL and the +// recognize / path / url_for reverse-routing helpers, plus the Mapper DSL self +// the draw block runs against (verb matchers, resources/resource, root, +// namespace/scope, member/collection, constraints). The route compiler, +// recognizer and generator are the library; running the DSL block is the seam. + +// registerACRouteSet installs ActionDispatch::Routing::RouteSet and its Mapper +// DSL surface. +func (vm *VM) registerACRouteSet(routeSet, mapper *RClass) { + routeSet.smethods["new"] = &Method{name: "new", owner: routeSet, native: func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACRouteSet{rs: routing.NewRouteSet(), cls: routeSet} + }} + + // draw do … end — evaluate the routing DSL against a Mapper wrapper. + routeSet.define("draw", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("ArgumentError", "RouteSet#draw requires a block") + } + rs := self.(*ACRouteSet) + err := rs.rs.Draw(func(m *routing.Mapper) { + vm.callBlockSelf(blk, &ACMapper{m: m, cls: vm.cACMapper}, nil) + }) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return object.NilV + }) + + // recognize(method, path) — resolve a request to {controller, action, params}, + // or nil when nothing matches. + routeSet.define("recognize", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + rec, ok := self.(*ACRouteSet).rs.Recognize(apStr(apArg(args, 0)), apStr(apArg(args, 1))) + if !ok { + return object.NilV + } + return rackFromGo(rec.Params) + }) + + // path(name, **params) — the named-route path helper (post_path(id: 1)). + routeSet.define("path", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s, err := self.(*ACRouteSet).rs.Path(apStr(apArg(args, 0)), acAnyMap(lastHashOrNil(args))) + if err != nil { + raise("ActionController::UrlGenerationError", "%s", err.Error()) + } + return object.NewString(s) + }) + + // path_args(name, *args) — positional path helper (post_path(1)); a trailing + // Hash supplies extra options. + routeSet.define("path_args", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } + s, err := self.(*ACRouteSet).rs.PathArgs(apStr(args[0]), acAnyArgs(args[1:])...) + if err != nil { + raise("ActionController::UrlGenerationError", "%s", err.Error()) + } + return object.NewString(s) + }) + + // url_for(controller:, action:, **params) — reverse routing from an options + // Hash. + routeSet.define("url_for", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s, err := self.(*ACRouteSet).rs.UrlFor(acAnyMap(lastHashOrNil(args))) + if err != nil { + raise("ActionController::UrlGenerationError", "%s", err.Error()) + } + return object.NewString(s) + }) + + // routes — the routes in definition order, each as a small Hash. + routeSet.define("routes", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + routes := self.(*ACRouteSet).rs.Routes() + out := make([]object.Value, len(routes)) + for i, r := range routes { + h := object.NewHash() + h.Set(object.NewString("name"), object.NewString(r.Name)) + h.Set(object.NewString("verb"), object.NewString(r.Verb)) + h.Set(object.NewString("spec"), object.NewString(r.Spec)) + h.Set(object.NewString("controller"), object.NewString(r.Controller)) + h.Set(object.NewString("action"), object.NewString(r.Action)) + out[i] = h + } + return object.NewArrayFromSlice(out) + }) + + vm.registerACMapper(mapper) +} + +// registerACMapper installs the routes-DSL surface a `draw` block runs against. +func (vm *VM) registerACMapper(cls *RClass) { + self := func(v object.Value) *routing.Mapper { return v.(*ACMapper).m } + + // Verb matchers: get/post/put/patch/delete "path", to:, as:, via:, … + for name, fn := range map[string]func(*routing.Mapper, string, ...routing.Option){ + "get": (*routing.Mapper).Get, + "post": (*routing.Mapper).Post, + "put": (*routing.Mapper).Put, + "patch": (*routing.Mapper).Patch, + "delete": (*routing.Mapper).Delete, + "match": (*routing.Mapper).Match, + } { + verb := fn + cls.define(name, func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + verb(self(v), acPath(args), acRouteOptions(args)...) + return object.NilV + }) + } + + // root to: "home#index" (or root "home#index"). + cls.define("root", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).Root(acTarget(args)) + return object.NilV + }) + + // resources :posts [do … end] — a plural RESTful resource. + cls.define("resources", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Resources(apStr(apArg(args, 0)), vm.acSubMapper(blk), acResOptions(args)...) + return object.NilV + }) + + // resource :profile [do … end] — a singular RESTful resource. + cls.define("resource", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Resource(apStr(apArg(args, 0)), vm.acSubMapper(blk), acResOptions(args)...) + return object.NilV + }) + + // namespace :admin do … end — a path/module/name-prefixed block. + cls.define("namespace", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Namespace(apStr(apArg(args, 0)), vm.acReqBlock(blk, "namespace")) + return object.NilV + }) + + // scope path:/module:/as: do … end — a scoped block. + cls.define("scope", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Scope(acScopeOpts(args), vm.acReqBlock(blk, "scope")) + return object.NilV + }) + + // member / collection do … end — resource-level route groups. + cls.define("member", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + self(v).Member(vm.acReqBlock(blk, "member")) + return object.NilV + }) + cls.define("collection", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + self(v).Collection(vm.acReqBlock(blk, "collection")) + return object.NilV + }) + + // constraints(id: /\d+/) do … end — added segment constraints. + cls.define("constraints", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).ConstraintsBlock(apStrMap(apArg(args, 0)), vm.acReqBlock(blk, "constraints")) + return object.NilV + }) +} + +// acSubMapper adapts an optional resources/resource block into the library's +// func(*Mapper): nil when no block was given (the resource generates its default +// routes), else a closure running the block against a sub-Mapper wrapper. +func (vm *VM) acSubMapper(blk *Proc) func(*routing.Mapper) { + if blk == nil { + return nil + } + return func(sub *routing.Mapper) { + vm.callBlockSelf(blk, &ACMapper{m: sub, cls: vm.cACMapper}, nil) + } +} + +// acReqBlock adapts a required DSL block into the library's func(*Mapper), +// raising when the block is absent (namespace/scope/member/collection/ +// constraints all take a mandatory block). +func (vm *VM) acReqBlock(blk *Proc, name string) func(*routing.Mapper) { + if blk == nil { + raise("ArgumentError", "%s requires a block", name) + } + return func(sub *routing.Mapper) { + vm.callBlockSelf(blk, &ACMapper{m: sub, cls: vm.cACMapper}, nil) + } +} + +// acPath returns the route path (the first positional, non-Hash argument), or +// "/" when only keyword options were given. +func acPath(args []object.Value) string { + if len(args) > 0 { + if _, ok := args[0].(*object.Hash); !ok { + return apStr(args[0]) + } + } + return "/" +} + +// acTarget returns a route's dispatch target: the first positional String, else +// the `to:` option ("controller#action"). +func acTarget(args []object.Value) string { + if len(args) > 0 { + if _, ok := args[0].(*object.Hash); !ok { + return apStr(args[0]) + } + } + if h := lastHashOrNil(args); h != nil { + if v, ok := h.Get(object.Symbol("to")); ok { + return apStr(v) + } + } + return "" +} + +// acRouteOptions reads a route declaration's keyword options into the library's +// []routing.Option (to:/as:/via:/action:/controller:/constraints:/defaults:/on:). +func acRouteOptions(args []object.Value) []routing.Option { + h := lastHashOrNil(args) + if h == nil { + return nil + } + var out []routing.Option + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "to": + out = append(out, routing.To(apStr(val))) + case "as": + out = append(out, routing.As(apStr(val))) + case "via": + out = append(out, routing.Via(apStrList(val)...)) + case "action": + out = append(out, routing.Action(apStr(val))) + case "controller": + out = append(out, routing.Controller(apStr(val))) + case "constraints": + out = append(out, routing.Constraints(apStrMap(val))) + case "defaults": + out = append(out, routing.Defaults(apStrMap(val))) + case "on": + out = append(out, routing.On(apStr(val))) + } + } + return out +} + +// acResOptions reads a resources/resource declaration's keyword options into the +// library's []routing.ResOption (only:/except:/path:/controller:/param:). +func acResOptions(args []object.Value) []routing.ResOption { + h := lastHashOrNil(args) + if h == nil { + return nil + } + var out []routing.ResOption + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "only": + out = append(out, routing.Only(apStrList(val)...)) + case "except": + out = append(out, routing.Except(apStrList(val)...)) + case "path": + out = append(out, routing.PathName(apStr(val))) + case "controller": + out = append(out, routing.ResController(apStr(val))) + case "param": + out = append(out, routing.ResParam(apStr(val))) + } + } + return out +} + +// acScopeOpts reads a scope declaration's keyword options into routing.ScopeOpts. +func acScopeOpts(args []object.Value) routing.ScopeOpts { + var opts routing.ScopeOpts + h := lastHashOrNil(args) + if h == nil { + return opts + } + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "path": + opts.Path = apStr(val) + case "module": + opts.Module = apStr(val) + case "as": + opts.As = apStr(val) + case "constraints": + opts.Constraints = apStrMap(val) + case "defaults": + opts.Defaults = apStrMap(val) + } + } + return opts +} + +// acAnyMap reads a Ruby Hash into the map[string]any the reverse-routing helpers +// consume (each value mapped through rackToGo); a nil Hash yields an empty map. +func acAnyMap(h *object.Hash) map[string]any { + out := map[string]any{} + if h == nil { + return out + } + for _, k := range h.Keys { + val, _ := h.Get(k) + out[apStr(k)] = rackToGo(val) + } + return out +} + +// acAnyArgs maps positional path_args into the []any the helper consumes: a +// trailing Hash becomes a map[string]any option bag, every other argument its +// rackToGo scalar. +func acAnyArgs(args []object.Value) []any { + out := make([]any, 0, len(args)) + for i, a := range args { + if h, ok := a.(*object.Hash); ok && i == len(args)-1 { + out = append(out, acAnyMap(h)) + continue + } + out = append(out, rackToGo(a)) + } + return out +} + +// lastHashOrNil returns the trailing keyword-options Hash of an argument list, +// or nil when the last argument is not a Hash. +func lastHashOrNil(args []object.Value) *object.Hash { + if h, ok := lastHash(args); ok { + return h + } + return nil +} diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6721fcd8..dd2e4dea 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -495,6 +495,7 @@ func (vm *VM) bootstrap() { vm.registerActiveJob() // ActiveJob::Base subclass DSL (queue_as/retry_on/callbacks/perform_later/perform_now/set) + Arguments (require "active_job"), backed by go-ruby-activejob; #perform is a Ruby seam run INLINE under the GVL (default inline adapter); needs ArgumentError for SerializationError 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.registerActionPack() // ActionController::Base + ActionController::Parameters + ActionDispatch::Routing::RouteSet/Request/Response (require "action_controller" / "action_dispatch"), backed by go-ruby-actionpack; the action body (RunAction), the before/after/around filters, rescue_from and the view render (Renderer -> an ActionView-style context) are the rbgo seams run inline under the GVL; the route compiler/recognizer/generator, filter/rescue pipeline and strong-params semantics are the library; needs StandardError + Rack, so registered after registerRack/registerActionCable 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.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 diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 297665ed..98e2f88a 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1334,6 +1334,16 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *OmniAuthHash: return x.cls + case *ACRouteSet: + return x.cls + case *ACMapper: + return x.cls + case *ACParams: + return x.cls + case *ACRequest: + return x.cls + case *ACResponse: + return x.cls case *Binding: return vm.consts["Binding"].(*RClass) case *JobRedis: diff --git a/internal/vm/require.go b/internal/vm/require.go index 1ce3dd7d..98c3ef8e 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -78,6 +78,7 @@ var providedFeatures = map[string]bool{ "active_job": true, "activejob": true, "active_storage": true, "activestorage": true, "action_cable": true, "actioncable": true, + "action_controller": true, "action_dispatch": true, "actionpack": true, "abstract_controller": true, "rails": true, "rails/railtie": true, "rails/engine": true, "rails/application": true, "devise": true, "hanami": true, "hanami/router": true, "hanami/action": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 94d4017c..41128dd1 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -290,6 +290,13 @@ type VM struct { cHanamiResponse *RClass // Hanami::Action::Response, the mutable response a Hanami action's #handle writes into cHanamiFlash *RClass // Hanami::Action::Flash, the two-generation flash store on the request/response hanamiActionDefs map[*RClass]*hanamiActionDef // per-Hanami::Action-subclass before/after/handle_exception/accept/config declarations + cACRouteSet *RClass // ActionDispatch::Routing::RouteSet (require "action_dispatch"), backed by go-ruby-actionpack/routing + cACMapper *RClass // ActionDispatch::Routing::Mapper, the self the routes DSL (draw) runs against + cACRequest *RClass // ActionDispatch::Request, over a Rack env + cACResponse *RClass // ActionDispatch::Response, the mutable Rack response + cACParameters *RClass // ActionController::Parameters (strong parameters) + cACControllerBase *RClass // ActionController::Base (require "action_controller"), the controller superclass a user subclasses + acControllerDefs map[*RClass]*acControllerDef // per-ActionController::Base-subclass before/after/around/rescue_from/view-context declarations cMinitestSpec *RClass // Minitest::Spec, the spec-DSL subclass of Minitest::Test minitestRunnables []*RClass // Minitest::Test subclasses registered via the inherited hook, in definition order (the autorun run set) minitestCurInstance object.Value // the test instance currently running (backs bare must_*/wont_* and _) From d2f156f632beffd4d9c48774d12a880aa6e41f35 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 23:28:50 +0200 Subject: [PATCH 5/5] =?UTF-8?q?feat(vm):=20bind=20batch=204=20wave=202=20?= =?UTF-8?q?=E2=80=94=20actionview,=20actionpack,=20actionmailer,=20rails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the 4 render-coupled Rails bindings (each verified 100% cov + full suite + 6-arch on its own branch before consolidation): - action_view — Base view context (tag/url/form/text/number helpers + FormBuilder + PartialIteration) + ActiveSupport::SafeBuffer; RenderTemplate seam defaults to inline ERB via go-ruby-erb, #render kept dispatchable - action_controller/action_dispatch (actionpack) — RouteSet/Mapper DSL + recognize/url_for, ActionController::Base filters/rescue_from/render/redirect, strong Parameters, Request/Response; Renderer seam dispatches ActionView at the Ruby level - action_mailer — Base subclass DSL + MessageDelivery proxy + mail/attachments; RenderBody->ActionView, EnqueueJob->ActiveJob via injectable renderer=/enqueuer= seams, :test delivery default - rails (meta) — extends railties' Rails module with top-level accessors (Rails.application/env/root/…), Rails::VERSION, EnvironmentInquirer, and a rails/all hook that requires the catalog's available components Merged on top of main's #192 (UDP/UNIX socket + server-side TLS). All seams run Ruby *Proc bodies inline on the VM goroutine under the GVL. Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 ++ go.sum | 4 ++++ internal/vm/require.go | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 0512fac8..f56c9fd7 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( github.com/go-ruby-abbrev/abbrev v0.0.0-20260629150957-97117892cd38 github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 + github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917 + github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5 github.com/go-ruby-actionview/actionview v0.0.0-20260706115423-0e293eed051a github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a diff --git a/go.sum b/go.sum index 865478b9..a357f88a 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,10 @@ github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe h1:oYFDl+kcAkTOX github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe/go.mod h1:Bh8Vr5Vy9UO7JqU5f6myUOWl0jjnnvtlGdIVTBbdLRw= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 h1:VAFOmKa1u2HKA2LFQhelusPVIAtgp+wAOkXl8IjURbQ= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783/go.mod h1:5DS3icNZKImRwvDP7Kf9IHv+9d6EYtcvRpBALgh/s/Y= +github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917 h1:f1xzBCccdUn49fRLh8XBn/d9OHpcrNa46w6aW0hGjyY= +github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917/go.mod h1:CgixBW5KpbPEx+8SCZJQbeVa2WLNB7ObGRRCsjDEY+Y= +github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5 h1:+CxMtA9K0j7BWiWdq+/rMfgeWjViJStyfVKcvMlDSvA= +github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5/go.mod h1:3lXE8Ou6UitS4sOxQhn/KCI6eKLdREUcFxsky4oMEbk= github.com/go-ruby-actionview/actionview v0.0.0-20260706115423-0e293eed051a h1:NhatSlzDFDTNAIYng5hhUltGPS0O5xiPr5efQ+RsL9k= github.com/go-ruby-actionview/actionview v0.0.0-20260706115423-0e293eed051a/go.mod h1:n9SEnqxWmYIXKgtrIZEiRvGui0lWdCd2GJELRpa44ls= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 h1:vRJAWboG75rQWpfTiNQ8XkFRJ1Am9RNKdRKSlTLQBiA= diff --git a/internal/vm/require.go b/internal/vm/require.go index 3adf2f10..281b7a1d 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -82,8 +82,8 @@ var providedFeatures = map[string]bool{ "action_controller": true, "action_dispatch": true, "actionpack": true, "abstract_controller": 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, + "devise": true, + "hanami": true, "hanami/router": true, "hanami/action": true, "warden": true, "omniauth": true, "public_suffix": true, "mime/types": true, "mail": true, "faker": true, "action_mailer": true, "actionmailer": true,