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
9 changes: 9 additions & 0 deletions internal/vm/sinatra_bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,15 @@ func (vm *VM) registerSinatraContext(ctx *RClass) {
ctx.define("uri", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value {
return object.NewString(self(v).c.URI(sinatraStr(rackArg(args))))
})
// erb(template, options = {}, locals = {}) — render an ERB view through the
// 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.
ctx.define("erb", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value {
return vm.sinatraErb(self(v), args)
})
ctx.define("url", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value {
return object.NewString(self(v).c.URI(sinatraStr(rackArg(args))))
})
Expand Down
136 changes: 136 additions & 0 deletions internal/vm/sinatra_erb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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/object"
)

// This file wires Sinatra's `erb` view helper to the already-bound go-ruby-erb
// compiler. A route/filter block runs against a SinatraCtx (self); `erb` there
// compiles a template to Ruby source (go-ruby-erb, mirroring MRI's ERB::Compiler
// byte for byte) and evaluates that source in the handler's binding — self = the
// SinatraCtx, so the action's @ivars and the request helpers (params, request,
// …) resolve, plus any `locals:` bound as local variables — capturing the
// _erbout buffer and returning the rendered String. That String becomes the Rack
// response body via sinatraResult, exactly as under MRI Sinatra + Tilt.
//
// Trim mode and the output-buffer name match Tilt::ERBTemplate's defaults, which
// Sinatra uses unchanged: trim "<>" (a code-only line's surrounding newlines are
// removed) and eoutvar "_erbout". <%= %> interpolates unescaped (Sinatra does not
// 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).

// 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
// only a code tag has both its surrounding newlines trimmed.
const sinatraTrimMode = "<>"

// sinatraEOutVar names the output buffer the compiled template appends to, matching
// Tilt::ERBTemplate's default output variable.
const sinatraEOutVar = "_erbout"

// sinatraErb implements the `erb` view helper on a request context. template is
// either an inline template String or a :symbol naming views/<sym>.erb; options
// is the (largely reserved) options Hash whose :locals entry, if present, seeds
// locals; locals is the positional locals Hash. The template is compiled through
// go-ruby-erb and evaluated in the handler's binding, returning the rendered
// String.
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])
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)
}

// sinatraErbTemplate resolves the first `erb` argument to the template source: an
// inline String is used verbatim; a :symbol is resolved to views/<sym>.erb and
// read from disk (through the File binding), the views dir coming from the app's
// :views setting (default "./views"). Any other type is a Sinatra-style error.
func (vm *VM) sinatraErbTemplate(sc *SinatraCtx, tmpl object.Value) string {
switch t := tmpl.(type) {
case *object.String:
return t.Str()
case object.Symbol:
return vm.sinatraErbReadView(sc, string(t))
}
raise("ArgumentError", "erb: template must be a String or Symbol, got %s", classNameOf(tmpl))
return ""
}

// sinatraErbReadView reads the ERB template file views/<name>.erb, the views dir
// taken from the app's :views setting (a String) or defaulting to "./views" as in
// Sinatra. Reading goes through the File binding so it obeys the same host
// 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")
// 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()
}

// 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
// name visible to <%= %> / <% %> in the template.
func sinatraErbLocals(args []object.Value) (names []string, vals []object.Value) {
seen := map[string]int{}
put := func(name string, v object.Value) {
if i, ok := seen[name]; ok {
vals[i] = v
return
}
seen[name] = len(names)
names = append(names, name)
vals = append(vals, v)
}
addHash := func(h *object.Hash) {
for _, k := range h.Keys {
v, _ := h.Get(k)
put(sinatraStr(k), v)
}
}
// options[:locals] — Sinatra also accepts locals passed inside the options Hash.
if len(args) >= 2 {
if h, ok := args[1].(*object.Hash); ok {
if lv, ok := h.Get(object.Symbol("locals")); ok {
if lh, ok := lv.(*object.Hash); ok {
addHash(lh)
}
}
}
}
// The positional locals Hash (3rd argument), which overrides options[:locals].
if len(args) >= 3 {
if h, ok := args[2].(*object.Hash); ok {
addHash(h)
}
}
return names, vals
}
16 changes: 16 additions & 0 deletions internal/vm/sinatra_erb_closed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build rbgo_closed

package vm

import "github.com/go-embedded-ruby/ruby/internal/object"

// sinatraErbEval is unavailable in a closed-world binary: rendering a template
// evaluates its compiled Ruby source, which needs the front-end (parser +
// 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
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
}
216 changes: 216 additions & 0 deletions internal/vm/sinatra_erb_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

package vm

import (
"errors"
"os"
"path/filepath"
"testing"

erb "github.com/go-ruby-erb/erb"
)

// sinatraErbApp wraps a single route body in a Sinatra::Base app and dispatches
// GET path through the Rack #call adapter, returning the joined response body.
// The route runs against a SinatraCtx, so `erb` inside it exercises the whole
// bridge (compile via go-ruby-erb, evaluate in the handler's binding).
func sinatraErbApp(route, path, query string) string {
return `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
before { @n = 3; @who = "World" }
` + route + `
end
_, _, b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"` + path + `","QUERY_STRING"=>"` + query + `","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
print b.join`
}

// TestSinatraErbInlineRender covers the inline-String render paths: <%= %>
// interpolation of a query param and an @ivar set in a before-filter, <% %>
// control flow, and ERB trim behaviour — the core of sinatraErb / sinatraErbEval.
func TestSinatraErbInlineRender(t *testing.T) {
cases := []struct{ route, path, query, want string }{
// The headline: param + @ivar interpolation.
{`get("/inline"){ erb "<h1>Hello <%= params['name'] %> (<%= @n %> visits)</h1>" }`,
"/inline", "name=amy", "<h1>Hello amy (3 visits)</h1>"},
// Control flow, single line (trim-neutral).
{`get("/loop"){ erb "<ul><% %w[a b c].each do |x| %><li><%= x %></li><% end %></ul>" }`,
"/loop", "", "<ul><li>a</li><li>b</li><li>c</li></ul>"},
// Trim: code-only lines standing alone have their surrounding newlines cut.
{`get("/trim"){ erb "line1\n<% if true %>\nkept\n<% end %>\nline2" }`,
"/trim", "", "line1\nkept\nline2"},
}
for _, c := range cases {
if got := runFS(t, sinatraErbApp(c.route, c.path, c.query)); got != c.want {
t.Errorf("route %q: got=%q want=%q", c.route, got, c.want)
}
}
}

// TestSinatraErbLocals covers sinatraErbLocals' collection arms: positional
// locals, options[:locals], a positional entry overriding an options[:locals]
// entry of the same name, and the non-Hash argument branches (options / locals
// that are not Hashes are ignored, matching the "no locals bound" outcome).
func TestSinatraErbLocals(t *testing.T) {
cases := []struct{ route, path, query, want string }{
// Positional locals (3rd argument).
{`get("/x"){ erb "<p><%= greeting %>, <%= who %>!</p>", {}, greeting: "Hi", who: params['name'] }`,
"/x", "name=bob", "<p>Hi, bob!</p>"},
// options[:locals].
{`get("/x"){ erb "<p><%= who %></p>", locals: { who: params['name'] } }`,
"/x", "name=eve", "<p>eve</p>"},
// A positional local overrides an options[:locals] local of the same name.
{`get("/x"){ erb "<p><%= who %></p>", { locals: { who: "opt" } }, who: "pos" }`,
"/x", "", "<p>pos</p>"},
// options[:locals] whose value is not a Hash is ignored (no locals bound).
{`get("/x"){ erb "ok", locals: 5 }`, "/x", "", "ok"},
// A non-Hash options argument is ignored.
{`get("/x"){ erb "ok", "notahash" }`, "/x", "", "ok"},
// A non-Hash positional locals argument is ignored.
{`get("/x"){ erb "ok", {}, "notahash" }`, "/x", "", "ok"},
}
for _, c := range cases {
if got := runFS(t, sinatraErbApp(c.route, c.path, c.query)); got != c.want {
t.Errorf("route %q: got=%q want=%q", c.route, got, c.want)
}
}
}

// TestSinatraErbSymbolView covers the :symbol template path: an ERB file under the
// app's :views directory is read (through the File binding) and rendered against
// the handler's binding (an @ivar plus positional locals).
func TestSinatraErbSymbolView(t *testing.T) {
dir := slash(t.TempDir())
view := "<h2><%= greeting %> <%= @who %></h2>\n<ul>\n<% items.each do |i| %>\n <li><%= i %></li>\n<% end %>\n</ul>\n"
if err := os.WriteFile(filepath.FromSlash(dir+"/card.erb"), []byte(view), 0o644); err != nil {
t.Fatal(err)
}
src := `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
set :views, "` + dir + `"
before { @who = "World" }
get("/v"){ erb :card, {}, greeting: "Hey", items: %w[x y] }
end
_, _, b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/v","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
print b.join`
want := "<h2>Hey World</h2>\n<ul>\n <li>x</li>\n <li>y</li>\n</ul>\n"
if got := runFS(t, src); got != want {
t.Errorf("symbol view: got=%q want=%q", got, want)
}
}

// TestSinatraErbViewsDirResolution covers sinatraErbReadView's views-directory
// resolution branches: the default "./views" when :views is unset, and the
// fallback to the default when :views is set to a non-String value. Both resolve
// to a missing file, so File.read raises Errno::ENOENT, proving the path used.
func TestSinatraErbViewsDirResolution(t *testing.T) {
// No :views set -> default "./views" (the file does not exist -> ENOENT).
noViews := `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
get("/v"){ begin; erb :nope_missing_view; rescue => e; halt 500, e.class.to_s; end }
end
s, _, b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/v","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
print "#{s}:#{b.join}"`
if got := runFS(t, noViews); got != "500:Errno::ENOENT" {
t.Errorf("default views dir: got=%q want %q", got, "500:Errno::ENOENT")
}

// :views set to a non-String (Integer) -> also falls back to the default dir.
nonStr := `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
set :views, 42
get("/v"){ begin; erb :nope_missing_view; rescue => e; halt 500, e.class.to_s; end }
end
s, _, b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/v","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
print "#{s}:#{b.join}"`
if got := runFS(t, nonStr); got != "500:Errno::ENOENT" {
t.Errorf("non-string views dir: got=%q want %q", got, "500:Errno::ENOENT")
}
}

// TestSinatraErbArgErrors covers the argument-validation branches: `erb` with no
// arguments, and a template that is neither a String nor a Symbol.
func TestSinatraErbArgErrors(t *testing.T) {
cases := []struct{ route, want string }{
{`get("/x"){ erb }`, "ArgumentError"},
{`get("/x"){ erb 42 }`, "ArgumentError"},
}
for _, c := range cases {
src := `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
` + c.route + `
end
begin
A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
rescue => e
print e.class
end`
if got := runFS(t, src); got != c.want {
t.Errorf("route %q: got=%q want=%q", c.route, got, c.want)
}
}
}

// TestSinatraErbTemplateEvalErrors covers sinatraErbEval's two failure arms: a
// template whose embedded Ruby is malformed (parse error) and one whose embedded
// Ruby parses but does not compile (a bare `retry`), both surfaced as SyntaxError.
func TestSinatraErbTemplateEvalErrors(t *testing.T) {
cases := []struct{ route, want string }{
{`get("/x"){ erb "<% ( %>" }`, "SyntaxError"}, // parse failure
{`get("/x"){ erb "<% retry %>" }`, "SyntaxError"}, // parses, fails to compile
}
for _, c := range cases {
src := `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
` + c.route + `
end
begin
A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
rescue Exception => e
print e.class
end`
if got := runFS(t, src); 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
// ArgumentError carrying the error's message.
func TestSinatraErbCompileError(t *testing.T) {
saved := erbCompile
defer func() { erbCompile = saved }()
erbCompile = func(string, erb.Options) (string, string, error) {
return "", "", errors.New("boom")
}
src := `require "sinatra/base"
class A < Sinatra::Base
disable :protection
set :host_authorization, { permitted_hosts: [] }
get("/x"){ erb "anything" }
end
begin
A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x","QUERY_STRING"=>"","rack.url_scheme"=>"http","HTTP_HOST"=>"ex")
rescue => e
print [e.class, e.message].inspect
end`
if got := runFS(t, src); got != `[ArgumentError, "boom"]` {
t.Errorf("compile-error injection: got=%q", got)
}
}
Loading
Loading