diff --git a/internal/vm/rack.go b/internal/vm/rack.go index eee44eea..257a0464 100644 --- a/internal/vm/rack.go +++ b/internal/vm/rack.go @@ -5,6 +5,8 @@ package vm import ( + "sort" + rack "github.com/go-ruby-rack/rack" "github.com/go-embedded-ruby/ruby/internal/object" @@ -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]) } diff --git a/internal/vm/rack_helpers.go b/internal/vm/rack_helpers.go index 1ce53191..ad29cdd9 100644 --- a/internal/vm/rack_helpers.go +++ b/internal/vm/rack_helpers.go @@ -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 { diff --git a/internal/vm/rack_internal_test.go b/internal/vm/rack_internal_test.go index 1cee9a8f..f04911ea 100644 --- a/internal/vm/rack_internal_test.go +++ b/internal/vm/rack_internal_test.go @@ -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 { @@ -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) @@ -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 {