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
79 changes: 78 additions & 1 deletion internal/vm/rack.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
package vm

import (
"sort"

rack "github.com/go-ruby-rack/rack"

"github.com/go-embedded-ruby/ruby/internal/object"
Expand Down Expand Up @@ -299,7 +301,82 @@ func (vm *VM) registerRackUtils(mod *RClass) {
def("build_query", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
return object.NewString(rack.BuildQuery(rackParamsFromHash(rackArg(args))))
})
def("parse_nested_query", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
p, err := rack.ParseNestedQuery(rackStr(rackArg(args)), rackSep(args, 1), 0)
if err != nil {
raise("ArgumentError", "%s", err.Error())
}
return rackParamsToHash(p)
})
def("build_nested_query", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
prefix := ""
if len(args) > 1 {
prefix = rackStr(args[1])
}
out, err := rack.BuildNestedQuery(rackToGoNested(rackArg(args)), prefix)
if err != nil {
raise("ArgumentError", "%s", err.Error())
}
return object.NewString(out)
})
def("q_values", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
qs := rack.QValues(rackStr(rackArg(args)))
out := object.NewArrayFromSlice(make([]object.Value, len(qs)))
for i, q := range qs {
out.Elems[i] = object.NewArray(object.NewString(q.Value), object.Float(q.Quality))
}
return out
})
def("best_q_match", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
if len(args) < 2 {
raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args))
}
m := rack.BestQMatch(rackStr(args[0]), rackStrArray(args[1]))
if m == "" {
return object.NilV
}
return object.NewString(m)
})
def("parse_cookies_header", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
return rackParamsToHash(rack.ParseCookiesHeader(rackStr(rackArg(args))))
})
def("parse_cookies", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
return rackParamsToHash(rack.ParseCookiesHeader(rackCookieHeader(rackArg(args))))
})
def("status_code", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
return object.IntValue(int64(rackInt(rackArg(args), 500)))
a := rackArg(args)
if sym, ok := a.(object.Symbol); ok {
code, found := rack.SymbolToStatusCode(string(sym))
if !found {
raise("ArgumentError", "Unrecognized status code :%s", string(sym))
}
return object.IntValue(int64(code))
}
return object.IntValue(int64(rackInt(a, 500)))
})
// HTTP_STATUS_CODES — the code → reason-phrase table (Rack::Utils::
// HTTP_STATUS_CODES), materialised as a Ruby Hash in ascending code order.
codes := make([]int, 0, len(rack.HTTPStatusCodes))
for c := range rack.HTTPStatusCodes {
codes = append(codes, c)
}
sort.Ints(codes)
statusTable := object.NewHash()
for _, c := range codes {
statusTable.Set(object.IntValue(int64(c)), object.NewString(rack.HTTPStatusCodes[c]))
}
util.consts["HTTP_STATUS_CODES"] = statusTable
}

// rackSep reads an optional query-separator argument at index i (Ruby's
// parse_nested_query second parameter). A missing or nil argument yields ""
// so the library falls back to its default ("&").
func rackSep(args []object.Value, i int) string {
if len(args) <= i {
return ""
}
if _, isNil := args[i].(object.Nil); isNil || args[i] == nil {
return ""
}
return rackStr(args[i])
}
58 changes: 58 additions & 0 deletions internal/vm/rack_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,64 @@ func rackFromGo(v any) object.Value {
return object.NilV
}

// rackToGoNested maps a Ruby value into the *structural* Go model
// rack.BuildNestedQuery consumes: a Hash becomes an insertion-ordered
// *rack.Params, an Array a []any and a scalar its rackToGo form. build_query
// only needs the flat model (rackToGo), but build_nested_query recurses into
// sub-hashes and expects *rack.Params for them — a plain map[string]any would
// lose Ruby's Hash order (and buildNested only recognises *Params for hashes),
// so nested query building goes through this converter instead.
func rackToGoNested(v object.Value) any {
switch n := v.(type) {
case *object.Hash:
p := rack.NewParams()
for _, k := range n.Keys {
val, _ := n.Get(k)
p.Set(rackStr(k), rackToGoNested(val))
}
return p
case *object.Array:
out := make([]any, len(n.Elems))
for i, el := range n.Elems {
out[i] = rackToGoNested(el)
}
return out
}
return rackToGo(v)
}

// rackStrArray maps a Ruby Array into a []string (each element stringified),
// used for the `available` list of Rack::Utils.best_q_match. A non-Array
// argument yields nil.
func rackStrArray(v object.Value) []string {
arr, ok := v.(*object.Array)
if !ok {
return nil
}
out := make([]string, len(arr.Elems))
for i, el := range arr.Elems {
out[i] = rackStr(el)
}
return out
}

// rackCookieHeader extracts the HTTP_COOKIE entry from a Ruby env Hash for
// Rack::Utils.parse_cookies(env), matching MRI which reads env['HTTP_COOKIE'].
// A non-Hash argument or an absent key yields the empty string (no cookies).
func rackCookieHeader(v object.Value) string {
h, ok := v.(*object.Hash)
if !ok {
return ""
}
for _, k := range h.Keys {
if rackStr(k) == "HTTP_COOKIE" {
val, _ := h.Get(k)
return rackStr(val)
}
}
return ""
}

// rackToGo maps a Ruby value into the generic Go value model rack consumes
// (nil / bool / int64 / float64 / string / []any / map[string]any).
func rackToGo(v object.Value) any {
Expand Down
67 changes: 67 additions & 0 deletions internal/vm/rack_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,25 @@ func TestRackUtils(t *testing.T) {
{`require "rack/utils"; puts Rack::Utils.unescape_path("a%20b")`, "a b\n"},
{`require "rack/utils"; puts Rack::Utils.parse_query("a=1&b=2")["a"]`, "1\n"},
{`require "rack/utils"; puts Rack::Utils.build_query("a"=>"1")`, "a=1\n"},
{`require "rack/utils"; puts Rack::Utils.build_query("a"=>["1","2"])`, "a=1&a=2\n"},
{`require "rack/utils"; puts Rack::Utils.parse_nested_query("a[b][c]=1")["a"]["b"]["c"]`, "1\n"},
{`require "rack/utils"; puts Rack::Utils.parse_nested_query("e[]=x&e[]=y")["e"].inspect`, `["x", "y"]` + "\n"},
{`require "rack/utils"; puts Rack::Utils.parse_nested_query("a=1;b=2", ";")["b"]`, "2\n"},
{`require "rack/utils"; puts Rack::Utils.parse_nested_query("a=1", nil)["a"]`, "1\n"},
{`require "rack/utils"; puts Rack::Utils.build_nested_query("a"=>{"b"=>"1"})`, "a%5Bb%5D=1\n"},
{`require "rack/utils"; puts Rack::Utils.build_nested_query(["x","y"], "k")`, "k%5B%5D=x&k%5B%5D=y\n"},
{`require "rack/utils"; puts Rack::Utils.q_values("text/html;q=0.5").inspect`, `[["text/html", 0.5]]` + "\n"},
{`require "rack/utils"; puts Rack::Utils.best_q_match("text/html", ["text/html","application/json"])`, "text/html\n"},
{`require "rack/utils"; p Rack::Utils.best_q_match("image/png", ["text/html"])`, "nil\n"},
{`require "rack/utils"; puts Rack::Utils.parse_cookies_header("a=1; b=2")["b"]`, "2\n"},
{`require "rack/utils"; puts Rack::Utils.parse_cookies("HTTP_COOKIE"=>"a=1; b=2")["a"]`, "1\n"},
{`require "rack/utils"; puts Rack::Utils.parse_cookies({})["a"].inspect`, "nil\n"},
{`require "rack/utils"; puts Rack::Utils.status_code(404)`, "404\n"},
{`require "rack/utils"; puts Rack::Utils.status_code(:not_found)`, "404\n"},
{`require "rack/utils"; puts Rack::Utils.status_code(:unprocessable_entity)`, "422\n"},
{`require "rack/utils"; puts Rack::Utils.status_code("500")`, "500\n"},
{`require "rack/utils"; puts Rack::Utils::HTTP_STATUS_CODES[404]`, "Not Found\n"},
{`require "rack/utils"; puts Rack::Utils::HTTP_STATUS_CODES[200]`, "OK\n"},
}
for _, c := range cases {
if got := eval(t, c.src); got != c.want {
Expand All @@ -182,6 +200,10 @@ func TestRackUtils(t *testing.T) {
for _, src := range []string{
`require "rack/utils"; Rack::Utils.unescape("%ZZ")`,
`require "rack/utils"; Rack::Utils.parse_query("a=%ZZ")`,
`require "rack/utils"; Rack::Utils.parse_nested_query("a=%ZZ")`,
`require "rack/utils"; Rack::Utils.build_nested_query("scalar")`,
`require "rack/utils"; Rack::Utils.status_code(:bogus)`,
`require "rack/utils"; Rack::Utils.best_q_match("x")`,
} {
if class, _ := evalErr(t, src); class != "ArgumentError" {
t.Errorf("src=%q class=%q want ArgumentError", src, class)
Expand Down Expand Up @@ -293,6 +315,51 @@ func TestRackParamsFromHash(t *testing.T) {
}
}

// TestRackToGoNested covers the Hash → *Params, Array → []any and scalar
// delegation arms of the nested build_nested_query converter.
func TestRackToGoNested(t *testing.T) {
inner := object.NewHash()
inner.Set(object.NewString("b"), object.NewString("1"))
outer := object.NewHash()
outer.Set(object.NewString("a"), inner)
got, err := rack.BuildNestedQuery(rackToGoNested(outer), "")
if err != nil || got != "a%5Bb%5D=1" {
t.Errorf("hash arm got=%q err=%v", got, err)
}
if arr, ok := rackToGoNested(object.NewArray(object.NewString("x"))).([]any); !ok || len(arr) != 1 {
t.Errorf("array arm got=%#v", rackToGoNested(object.NewArray(object.NewString("x"))))
}
if s, ok := rackToGoNested(object.NewString("s")).(string); !ok || s != "s" {
t.Errorf("scalar arm got=%#v", rackToGoNested(object.NewString("s")))
}
}

// TestRackStrArray covers the Array and non-Array arms.
func TestRackStrArray(t *testing.T) {
if rackStrArray(object.NilV) != nil {
t.Error("non-Array arm")
}
got := rackStrArray(object.NewArray(object.NewString("a"), object.Integer(2)))
if !reflect.DeepEqual(got, []string{"a", "2"}) {
t.Errorf("array arm got=%#v", got)
}
}

// TestRackCookieHeader covers the present-key, absent-key and non-Hash arms.
func TestRackCookieHeader(t *testing.T) {
if rackCookieHeader(object.Integer(1)) != "" {
t.Error("non-Hash arm")
}
if rackCookieHeader(object.NewHash()) != "" {
t.Error("absent-key arm")
}
h := object.NewHash()
h.Set(object.NewString("HTTP_COOKIE"), object.NewString("a=1"))
if rackCookieHeader(h) != "a=1" {
t.Errorf("present-key arm got=%q", rackCookieHeader(h))
}
}

// TestRackParamsAndHeadersNil covers the nil-guard arms.
func TestRackParamsAndHeadersNil(t *testing.T) {
if rackParamsToHash(nil).Len() != 0 {
Expand Down
Loading