From dc55e9eab6999ef320ab707e53461ab40a511096 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 23:16:38 +0200 Subject: [PATCH] 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")`, "/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