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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions internal/vm/sinatra_bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,10 @@ func (vm *VM) registerSinatraContext(ctx *RClass) {
// go-ruby-erb compiler and return the rendered String (which becomes the
// response body). template is an inline template String or a :symbol naming
// views/<sym>.erb; the compiled source evaluates in this handler's binding, so
// the action's @ivars and the request helpers resolve and `locals:` bind. See
// sinatra_erb.go. Layouts/partials and :erb escape_html are scoped follow-ups.
// the action's @ivars and the request helpers resolve and `locals:` bind. A
// layout (views/layout.erb by default, :layout => false/:name to override)
// wraps the view with its String exposed as `yield`. See sinatra_erb.go.
// Partials and :erb escape_html are scoped follow-ups.
ctx.define("erb", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value {
return vm.sinatraErb(self(v), args)
})
Expand Down
144 changes: 130 additions & 14 deletions internal/vm/sinatra_erb.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,19 @@ import (
// auto-escape unless :erb => { escape_html: true } is configured — a scoped
// follow-up), <% %> is control flow.
//
// Two render paths land: an inline-String template (needs no filesystem) and a
// :symbol template naming views/<sym>.erb (read through the File binding, with
// the views dir taken from settings :views, default "./views"). Layouts and
// partials are flagged as a scoped follow-up (see registerSinatraContext).
// Two view render paths land: an inline-String template (needs no filesystem)
// and a :symbol template naming views/<sym>.erb (read through the File binding,
// with the views dir taken from settings :views, default "./views").
//
// Layouts wrap that view exactly as MRI Sinatra does. By default `erb :view`
// renders views/layout.erb (if present) with the view's rendered String exposed
// as `yield`, so the layout's `<%= yield %>` interpolates the inner content; a
// missing default layout is silently skipped (MRI's `catch(:layout_missing)`).
// `erb :view, layout: false` renders the view with no layout; `layout: :name`
// (or a template String) selects a custom layout, and a missing *named* layout
// raises Errno::ENOENT (MRI eats the error only for the default layout). Partials
// (`erb :part` composed inside a view via a helper) and the :erb escape_html /
// app-level `set :erb, layout:` engine options remain a scoped follow-up.

// sinatraTrimMode is the ERB trim mode Sinatra renders with: Tilt::ERBTemplate
// maps a nil/true :trim option (Sinatra's default) to "<>", so a line holding
Expand All @@ -49,16 +58,118 @@ func (vm *VM) sinatraErb(sc *SinatraCtx, args []object.Value) object.Value {
if len(args) == 0 {
raise("ArgumentError", "wrong number of arguments (given 0, expected 1..3)")
}
tmpl := vm.sinatraErbTemplate(sc, args[0])
names, vals := sinatraErbLocals(args)
// Render the view itself. A plain view is evaluated with no block, so a bare
// `yield` in a view (as opposed to a layout) raises LocalJumpError, matching MRI.
view := vm.sinatraErbRender(sc, vm.sinatraErbTemplate(sc, args[0]), names, vals, nil)
return vm.sinatraErbLayout(sc, args, view, names, vals)
}

// sinatraErbRender compiles one ERB template String through go-ruby-erb and
// evaluates it in the handler's binding, binding the render locals and, when
// non-nil, the yield block (used to expose the wrapped view to a layout's
// `<%= yield %>`). It returns the rendered String.
func (vm *VM) sinatraErbRender(sc *SinatraCtx, tmpl string, names []string, vals []object.Value, block *Proc) object.Value {
src, _, err := erbCompile(tmpl, erb.Options{TrimMode: sinatraTrimMode, EOutVar: sinatraEOutVar})
if err != nil {
// go-ruby-erb never fails on a well-formed template (its err is reserved for
// genuinely malformed options, unreachable through this fixed-option call);
// the branch is surfaced as an ArgumentError so the contract stays total.
raise("ArgumentError", "%s", err.Error())
}
names, vals := sinatraErbLocals(args)
return vm.sinatraErbEval(sc, src, names, vals)
return vm.sinatraErbEval(sc, src, names, vals, block)
}

// sinatraErbLayout applies Sinatra's layout rule to an already-rendered view: it
// reads the layout option from the `erb` options Hash (args[1]) and, if a layout
// applies, renders it with the same locals and a block whose `yield` returns the
// view's String, so the layout's `<%= yield %>` interpolates the inner content.
//
// The layout is chosen exactly as MRI Sinatra's render does:
// - no :layout key -> the default layout (views/layout.erb), missing = skipped
// - :layout => false/nil -> no layout (the view is returned verbatim)
// - :layout => true -> the default layout, but a missing file now raises
// - :layout => :name -> views/name.erb, a missing file raises
// - :layout => "src" -> an inline template String used as the layout
//
// (MRI eats a "layout missing" error only for the implicit default layout — an
// explicitly requested layout that is absent surfaces the Errno::ENOENT.)
func (vm *VM) sinatraErbLayout(sc *SinatraCtx, args []object.Value, view object.Value, names []string, vals []object.Value) object.Value {
val, present := sinatraLayoutOpt(args)
var (
name string // a :symbol/true layout resolves to views/<name>.erb
source string // a String layout is an inline template rendered directly
inline bool
eat bool // eat a missing layout file (only the implicit default layout)
)
switch {
case !present:
name, eat = "layout", true
default:
switch v := val.(type) {
case object.Symbol:
name = string(v)
case *object.String:
source, inline = v.Str(), true
default:
// true selects the default layout (with the error surfaced, not eaten);
// false / nil / any other falsy value means render the view with no layout.
if !val.Truthy() {
return view
}
name = "layout"
}
}

// The layout's `yield` returns the wrapped view's rendered String.
inner := view
yield := &Proc{native: func(*VM, []object.Value) object.Value { return inner }}

if inline {
return vm.sinatraErbRender(sc, source, names, vals, yield)
}
tmpl, found := vm.sinatraReadLayout(sc, name, eat)
if !found {
// The implicit default layout is absent — return the un-wrapped view, exactly
// as MRI's catch(:layout_missing) does.
return view
}
return vm.sinatraErbRender(sc, tmpl, names, vals, yield)
}

// sinatraLayoutOpt reads the :layout entry from the `erb` options Hash (the second
// positional argument), reporting whether the key was present so an absent key
// (default layout) is distinguished from an explicit `layout: false`.
func sinatraLayoutOpt(args []object.Value) (val object.Value, present bool) {
if len(args) >= 2 {
if h, ok := args[1].(*object.Hash); ok {
if v, ok := h.Get(object.Symbol("layout")); ok {
return v, true
}
}
}
return nil, false
}

// sinatraReadLayout reads the layout template file views/<name>.erb through the
// File binding, the views dir resolved as for a view (settings :views, default
// "./views"). When eat is true (the implicit default layout) a missing file is
// reported as found=false rather than raising, so the caller can skip the layout —
// MRI's catch(:layout_missing). When eat is false the file is read unconditionally,
// so a missing named layout raises Errno::ENOENT.
func (vm *VM) sinatraReadLayout(sc *SinatraCtx, name string, eat bool) (tmpl string, found bool) {
if eat && !vm.sinatraViewExists(sc, name) {
return "", false
}
return vm.sinatraErbReadView(sc, name), true
}

// sinatraViewExists reports whether views/<name>.erb exists, through the File
// binding (File.exist?), so the default-layout lookup obeys the same host
// filesystem seam as a view read.
func (vm *VM) sinatraViewExists(sc *SinatraCtx, name string) bool {
pathV := object.NewString(vm.sinatraViewsDir(sc) + "/" + name + ".erb")
return vm.send(vm.consts["File"].(*RClass), "exist?", []object.Value{pathV}, nil).Truthy()
}

// sinatraErbTemplate resolves the first `erb` argument to the template source: an
Expand All @@ -82,19 +193,24 @@ func (vm *VM) sinatraErbTemplate(sc *SinatraCtx, tmpl object.Value) string {
// filesystem seam as any File.read; a missing file raises Errno::ENOENT exactly as
// under Sinatra.
func (vm *VM) sinatraErbReadView(sc *SinatraCtx, name string) string {
dir := "./views"
if v, ok := sc.settings["views"]; ok {
if s, ok := v.(*object.String); ok {
dir = s.Str()
}
}
pathV := object.NewString(dir + "/" + name + ".erb")
pathV := object.NewString(vm.sinatraViewsDir(sc) + "/" + name + ".erb")
// File.read returns the file's contents as a String (or raises Errno::ENOENT for
// a missing view, which propagates exactly as under Sinatra).
res := vm.send(vm.consts["File"].(*RClass), "read", []object.Value{pathV}, nil)
return res.(*object.String).Str()
}

// sinatraViewsDir resolves the app's views directory: the :views setting (a
// String) or Sinatra's default "./views" when unset or set to a non-String value.
func (vm *VM) sinatraViewsDir(sc *SinatraCtx) string {
if v, ok := sc.settings["views"]; ok {
if s, ok := v.(*object.String); ok {
return s.Str()
}
}
return "./views"
}

// sinatraErbLocals collects the local variables an `erb` render binds, in a stable
// order (options[:locals] first, then the positional locals Hash, positional
// entries overriding). Keys are Symbols or Strings; each becomes a local variable
Expand Down
4 changes: 2 additions & 2 deletions internal/vm/sinatra_erb_closed.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import "github.com/go-embedded-ruby/ruby/internal/object"
// CompileWithLocals) that a closed-world build drops from the link — the same
// reason Binding#eval and Kernel#eval on source raise there. Routing/dispatch and
// the rest of the Sinatra DSL still work; only `erb` view rendering raises.
func (vm *VM) sinatraErbEval(sc *SinatraCtx, src string, names []string, vals []object.Value) object.Value {
_, _, _, _ = sc, src, names, vals
func (vm *VM) sinatraErbEval(sc *SinatraCtx, src string, names []string, vals []object.Value, block *Proc) object.Value {
_, _, _, _, _ = sc, src, names, vals, block
raise("NotImplementedError", "Sinatra#erb view rendering is unavailable in a closed-world binary (built with rbgo build --closed, without the front-end)")
return nil // unreachable: raise panics
}
89 changes: 89 additions & 0 deletions internal/vm/sinatra_erb_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,95 @@ end`
}
}

// sinatraLayoutApp writes card.erb / layout.erb / alt.erb into a fresh views dir
// and returns a Sinatra app (source) whose single /v route runs the given body,
// so a layout render exercises the whole bridge (view compile + eval, layout file
// read, and the yield block returning the view's String).
func sinatraLayoutApp(t *testing.T, route string) string {
t.Helper()
dir := slash(t.TempDir())
write := func(name, body string) {
if err := os.WriteFile(filepath.FromSlash(dir+"/"+name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("card.erb", "<h2><%= @who %></h2>")
write("layout.erb", "<main><%= yield %></main>")
write("alt.erb", "<alt class=\"<%= css %>\"><%= yield %></alt>")
return `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
set :views, "` + dir + `"
before { @who = "World" }
` + route + `
end
_, _, b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/v","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
print b.join`
}

// TestSinatraErbLayout covers the layout render arms of sinatraErbLayout with a
// views dir that holds layout.erb: the implicit default layout wrapping a view
// (its `<%= yield %>` returning the view's String), layout: false / nil (no
// layout), layout: true (the explicit default layout), a custom layout: :name
// file (with render locals reaching the layout), and an inline-String layout.
func TestSinatraErbLayout(t *testing.T) {
cases := []struct{ route, want string }{
// Implicit default layout — views/layout.erb wraps the view.
{`get("/v"){ erb :card }`, "<main><h2>World</h2></main>"},
// layout: false / nil — render the view with no layout.
{`get("/v"){ erb :card, layout: false }`, "<h2>World</h2>"},
{`get("/v"){ erb :card, layout: nil }`, "<h2>World</h2>"},
// layout: true — the default layout, explicitly requested.
{`get("/v"){ erb :card, layout: true }`, "<main><h2>World</h2></main>"},
// layout: :alt — a custom layout file; the render local (css) reaches it.
{`get("/v"){ erb :card, { layout: :alt }, css: "box" }`, `<alt class="box"><h2>World</h2></alt>`},
// An inline-String layout wraps the view via its `<%= yield %>`.
{`get("/v"){ erb :card, layout: "<x><%= yield %></x>" }`, "<x><h2>World</h2></x>"},
}
for _, c := range cases {
if got := runFS(t, sinatraLayoutApp(t, c.route)); got != c.want {
t.Errorf("route %q: got=%q want=%q", c.route, got, c.want)
}
}
}

// TestSinatraErbLayoutMissing covers the missing-layout arms: the implicit default
// layout is silently skipped when views/layout.erb is absent (MRI's
// catch(:layout_missing)), while an explicitly requested layout that is absent —
// a named layout: :sym or layout: true — raises Errno::ENOENT.
func TestSinatraErbLayoutMissing(t *testing.T) {
dir := slash(t.TempDir())
if err := os.WriteFile(filepath.FromSlash(dir+"/card.erb"), []byte("<x>"), 0o644); err != nil {
t.Fatal(err)
}
app := func(route string) string {
return `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
set :views, "` + dir + `"
before { @who = "World" }
` + route + `
end
_, _, b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/v","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
print b.join`
}
cases := []struct{ route, want string }{
// Implicit default layout absent -> skipped, the view renders un-wrapped.
{`get("/v"){ erb :card }`, "<x>"},
// A named layout that is absent raises Errno::ENOENT.
{`get("/v"){ begin; erb :card, layout: :nope; rescue => e; e.class.to_s; end }`, "Errno::ENOENT"},
// layout: true with an absent layout.erb also raises (MRI eats only the implicit default).
{`get("/v"){ begin; erb :card, layout: true; rescue => e; e.class.to_s; end }`, "Errno::ENOENT"},
}
for _, c := range cases {
if got := runFS(t, app(c.route)); got != c.want {
t.Errorf("route %q: got=%q want=%q", c.route, got, c.want)
}
}
}

// TestSinatraErbCompileError covers sinatraErb's otherwise-unreachable
// compile-error branch: go-ruby-erb never fails on a well-formed template, so the
// failure is injected through the erbCompile seam and must surface as a Ruby
Expand Down
14 changes: 9 additions & 5 deletions internal/vm/sinatra_erb_open.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ import (
// binding. It compiles the source in a child scope over a synthetic parent frame
// holding the locals (compiler.CompileWithLocals), seeds that parent env with the
// locals' values in slot order and runs the ISeq with the context as self. The
// compiled template's final expression is its output buffer, so exec returns the
// rendered String. It uses the front-end directly, so a closed-world build
// replaces it with the stub in sinatra_erb_closed.go.
func (vm *VM) sinatraErbEval(sc *SinatraCtx, src string, names []string, vals []object.Value) object.Value {
// block, when non-nil, is the template's yield target — a layout is evaluated
// with a block that returns the wrapped view's rendered String, so `<%= yield %>`
// in the layout interpolates the inner content, exactly as MRI Sinatra renders a
// layout with `render(...) { output }`. The compiled template's final expression
// is its output buffer, so exec returns the rendered String. It uses the
// front-end directly, so a closed-world build replaces it with the stub in
// sinatra_erb_closed.go.
func (vm *VM) sinatraErbEval(sc *SinatraCtx, src string, names []string, vals []object.Value, block *Proc) object.Value {
prog, perr := parser.Parse(src)
if perr != nil {
// go-ruby-erb emits valid Ruby for any well-formed template, so a parse error
Expand All @@ -38,5 +42,5 @@ func (vm *VM) sinatraErbEval(sc *SinatraCtx, src string, names []string, vals []
slots := make([]object.Value, len(vals))
copy(slots, vals)
parent := &Env{slots: slots}
return vm.exec(iseq, sc, nil, vm.classOf(sc), "", parent, nil, nil, nil)
return vm.exec(iseq, sc, nil, vm.classOf(sc), "", parent, block, nil, nil)
}
Loading
Loading