diff --git a/go.mod b/go.mod index 3f467ccc..d37ce17a 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/go-ruby-prime/prime v0.0.0-20260629151744-aa26747e21a8 github.com/go-ruby-pstore/pstore v0.0.0-20260630081017-0dd55a12f94e github.com/go-ruby-public-suffix/public-suffix v0.0.0-20260630151503-f308d4002444 + github.com/go-ruby-rack/rack v0.0.0-20260630124607-8a4f9cfcdbb5 github.com/go-ruby-redis/redis v0.0.0-20260701125752-5de216f6ad92 github.com/go-ruby-regexp/regexp v0.0.0-20260701043846-fa3d6604e1ee github.com/go-ruby-resolv/resolv v0.0.0-20260629153520-df410a5796ac @@ -76,6 +77,7 @@ require ( github.com/go-ruby-sequel/sequel v0.0.0-20260702151352-66413b601977 github.com/go-ruby-set/set v0.0.0-20260630204346-ab6a8a92a910 github.com/go-ruby-shellwords/shellwords v0.0.0-20260629114104-e941e4210818 + github.com/go-ruby-sinatra/sinatra v0.0.0-20260630133746-2c894e9d172c github.com/go-ruby-slim/slim v0.0.0-20260701141524-ade9ddf6aec4 github.com/go-ruby-sqlite3/sqlite3 v0.0.0-20260702143910-c9f337771f41 github.com/go-ruby-strscan/strscan v0.0.0-20260701044334-d0cc926643a8 diff --git a/go.sum b/go.sum index db4f97f4..6b0c48e8 100644 --- a/go.sum +++ b/go.sum @@ -134,6 +134,8 @@ github.com/go-ruby-pstore/pstore v0.0.0-20260630081017-0dd55a12f94e h1:JmbJL+0nq github.com/go-ruby-pstore/pstore v0.0.0-20260630081017-0dd55a12f94e/go.mod h1:tbYmHYXjnNXJCNYABDx9nWfuGBCXeKrp134bNsQeTpM= github.com/go-ruby-public-suffix/public-suffix v0.0.0-20260630151503-f308d4002444 h1:vkMPJDpZHmu09pVxr7CFppoh1b5Kc8LkhKpxkSBoyxA= github.com/go-ruby-public-suffix/public-suffix v0.0.0-20260630151503-f308d4002444/go.mod h1:Wq3cgP6klsULbO4ee0DOavVIzifgmddOWnYpqljbER4= +github.com/go-ruby-rack/rack v0.0.0-20260630124607-8a4f9cfcdbb5 h1:8jrY0g3k1+TbiXz7jV8sXP8UcXSlB1FKcXWkyXZPBQ8= +github.com/go-ruby-rack/rack v0.0.0-20260630124607-8a4f9cfcdbb5/go.mod h1:rKZ3vD81gJq61NvZ3XX9cr6AxtIV3+lFZKG7lPKV3PM= github.com/go-ruby-redis/redis v0.0.0-20260701125752-5de216f6ad92 h1:EXqNsTpyz5KPZURl94uOjcIIyJ0mCyMA7/AFAXvFgdg= github.com/go-ruby-redis/redis v0.0.0-20260701125752-5de216f6ad92/go.mod h1:gt67ft80L9J5DpRGe+x3qPy2hg1g75aWQ6h52inm7og= github.com/go-ruby-regexp/regexp v0.0.0-20260701043846-fa3d6604e1ee h1:ctauEAdAq129WCrU+dndYcOjNlo1Y94vWNBtgXp1heE= @@ -160,6 +162,8 @@ github.com/go-ruby-set/set v0.0.0-20260630204346-ab6a8a92a910 h1:Q41u365suJ1TlrX github.com/go-ruby-set/set v0.0.0-20260630204346-ab6a8a92a910/go.mod h1:F/P0PXeE5ybEPeWYfb1JAKWny1Li3dnlm6YxG2/vffE= github.com/go-ruby-shellwords/shellwords v0.0.0-20260629114104-e941e4210818 h1:z9b99kWJht8AgtlIbdiq7Y02j6eddxW+rr4MJpCS6kw= github.com/go-ruby-shellwords/shellwords v0.0.0-20260629114104-e941e4210818/go.mod h1:gJ+9ba+y8+DKenwjZKbTLVSuX6Xp7V5R7pZ6NwtD6x0= +github.com/go-ruby-sinatra/sinatra v0.0.0-20260630133746-2c894e9d172c h1:zq73EWvSARNK2T1R0WC3jl+YH7g6TutOkWd1btI8fIU= +github.com/go-ruby-sinatra/sinatra v0.0.0-20260630133746-2c894e9d172c/go.mod h1:nuEOODfga3YvXINu2Ckvp/dNTTZlx+LUyjpWI+9TMck= github.com/go-ruby-slim/slim v0.0.0-20260701141524-ade9ddf6aec4 h1:FYfHAxVaFU2q7BQ8wsKesUxO2uAQ2VK/TdTVut1/wfg= github.com/go-ruby-slim/slim v0.0.0-20260701141524-ade9ddf6aec4/go.mod h1:+y2RCJmyKesyDRfmIlOSHFbBzTgEIpch7rqkFqPunAw= github.com/go-ruby-sqlite3/sqlite3 v0.0.0-20260702143910-c9f337771f41 h1:KHSs2oPQsdj/VLap1eXUPWrlO6x4zyZlYoqga/rKavs= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index ecb8bbc8..b3c96af2 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -448,7 +448,9 @@ func (vm *VM) bootstrap() { vm.registerNokogiriXSLT() // Nokogiri::XSLT(str) -> Stylesheet#transform/apply_to (require "nokogiri"), backed by go-ruby-xslt over go-ruby-nokogiri; needs registerNokogiri first vm.registerRSpec() // RSpec matcher + expect surface (require "rspec"), backed by go-ruby-rspec; needs Exception for ExpectationNotMetError vm.registerRuboCop() // RuboCop::Runner#inspect/autocorrect + Cop::Offense (require "rubocop"), backed by go-ruby-rubocop over go-ruby-parser; needs StandardError for RuboCop::Error + vm.registerRack() // Rack::Request/Response/Utils (require "rack" / "rack/utils"), backed by go-ruby-rack; deterministic env/query/escape, no socket vm.registerGrape() // Grape::Router/Validator/Formatter (require "grape"), backed by go-ruby-grape; endpoint-block exec + Rack env are host seams; needs StandardError for Grape::Exceptions + vm.registerSinatra() // Sinatra::Base class-DSL + #call Rack adapter (require "sinatra/base"), backed by go-ruby-sinatra over go-ruby-rack; route/filter block eval is the rbgo seam; needs Rack + StandardError (Sinatra::NotFound), so registered after registerRack vm.registerActiveRecord() // ActiveRecord::Model/Relation/Record + Base.establish_connection (require "active_record"), backed by go-ruby-activerecord; adapter seam wired to go-ruby-sqlite3 so queries run; needs StandardError for ActiveRecordError vm.registerRQRCode() // RQRCode::QRCode (require "rqrcode"), backed by go-ruby-rqrcode; needs StandardError for RQRCode::QRCode*Error vm.registerDotenv() // Dotenv module (require "dotenv"), backed by go-ruby-dotenv; wires ENV read/write + shell seams diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index b885117c..bd81eb23 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -615,6 +615,16 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.cCSVRow case *CSVTable: return vm.cCSVTable + case *RackRequest: + // A Rack::Request wrapper reports the class stamped on it at construction. + return x.cls + case *RackResponse: + return x.cls + case *SinatraCtx: + // The self a Sinatra route/filter block runs against. + return x.cls + case *SinatraSettings: + return x.cls case *REXMLDocument: return vm.cREXMLDocument case *REXMLElement: diff --git a/internal/vm/rack.go b/internal/vm/rack.go new file mode 100644 index 00000000..49902dba --- /dev/null +++ b/internal/vm/rack.go @@ -0,0 +1,309 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + rack "github.com/go-ruby-rack/rack" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// rackRelease is the Rack::RELEASE version string the binding advertises. Rack +// is a wire/contract library, so RELEASE is the SPEC generation it implements +// (Rack 3), not the go-ruby-rack module version. +const rackRelease = "3.1.0" + +// RackRequest is the Ruby wrapper around a *rack.Request — the read-mostly view +// over a Rack env (Rack::Request). The env parsing, query decoding and header +// normalisation all live in github.com/go-ruby-rack/rack; this shell exposes the +// accessor surface (#path_info / #request_method / #params / …) to Ruby. The +// wrapper carries its own class so classOf reports Rack::Request. +type RackRequest struct { + req *rack.Request + cls *RClass +} + +func (r *RackRequest) ToS() string { return "#" } +func (r *RackRequest) Inspect() string { return r.ToS() } +func (r *RackRequest) Truthy() bool { return true } + +// RackResponse is the Ruby wrapper around a *rack.Response — the buffered +// status/headers/body a handler assembles (Rack::Response), yielding the SPEC +// [status, headers, body] triple through #finish / #to_a. +type RackResponse struct { + resp *rack.Response + cls *RClass +} + +func (r *RackResponse) ToS() string { return "#" } +func (r *RackResponse) Inspect() string { return r.ToS() } +func (r *RackResponse) Truthy() bool { return true } + +// registerRack installs the Rack module and its Request / Response value objects +// plus the Rack::Utils escaping/query surface (require "rack" / "rack/utils"): +// Rack::Request.new(env) exposes the request accessors over go-ruby-rack; +// Rack::Response.new(body, status, headers) buffers a response and #finish +// returns the [status, headers, body] triple a Rack server consumes; +// Rack::Utils.escape / unescape / escape_html / parse_query / build_query bind +// straight through to the library so encoding comes from one authoritative +// source. The whole contract is deterministic Go — no socket, no network. +func (vm *VM) registerRack() { + mod := newClass("Rack", nil) + mod.isModule = true + vm.consts["Rack"] = mod + + mod.consts["RELEASE"] = object.NewString(rackRelease) + + vm.registerRackRequest(mod) + vm.registerRackResponse(mod) + vm.registerRackUtils(mod) +} + +// registerRackRequest installs Rack::Request and its accessor methods. +func (vm *VM) registerRackRequest(mod *RClass) { + cls := newClass("Rack::Request", vm.cObject) + mod.consts["Request"] = cls + vm.consts["Rack::Request"] = cls + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + return &RackRequest{req: rack.NewRequest(rackEnv(args[0])), cls: cls} + }} + + self := func(v object.Value) *rack.Request { return v.(*RackRequest).req } + + // String accessors straight off the env. + str := func(fn func(*rack.Request) string) NativeFn { + return func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(fn(self(v))) + } + } + cls.define("request_method", str((*rack.Request).RequestMethod)) + cls.define("path_info", str((*rack.Request).PathInfo)) + cls.define("script_name", str((*rack.Request).ScriptName)) + cls.define("query_string", str((*rack.Request).QueryString)) + cls.define("server_name", str((*rack.Request).ServerName)) + cls.define("server_port", str((*rack.Request).ServerPort)) + cls.define("content_type", str((*rack.Request).ContentType)) + cls.define("media_type", str((*rack.Request).MediaType)) + cls.define("scheme", str((*rack.Request).Scheme)) + cls.define("host", str((*rack.Request).Host)) + cls.define("base_url", str((*rack.Request).BaseURL)) + cls.define("path", str((*rack.Request).Path)) + cls.define("fullpath", str((*rack.Request).Fullpath)) + cls.define("url", str((*rack.Request).URL)) + cls.define("ip", str((*rack.Request).IP)) + + // Boolean predicates. + boolean := func(fn func(*rack.Request) bool) NativeFn { + return func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(fn(self(v))) + } + } + cls.define("get?", boolean((*rack.Request).IsGet)) + cls.define("post?", boolean((*rack.Request).IsPost)) + cls.define("put?", boolean((*rack.Request).IsPut)) + cls.define("patch?", boolean((*rack.Request).IsPatch)) + cls.define("delete?", boolean((*rack.Request).IsDelete)) + cls.define("head?", boolean((*rack.Request).IsHead)) + cls.define("options?", boolean((*rack.Request).IsOptions)) + cls.define("xhr?", boolean((*rack.Request).XHR)) + cls.define("ssl?", boolean((*rack.Request).SSL)) + + cls.define("port", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(int64(self(v).Port())) + }) + + // #get_header(name) returns the raw env entry (or nil). + cls.define("get_header", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + raw, ok := self(v).GetHeaderRaw(rackStr(args[0])) + if !ok { + return object.NilV + } + return rackFromGo(raw) + }) + cls.define("has_header?", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + return object.Bool(self(v).HasHeader(rackStr(args[0]))) + }) + + // Parsed params as Ruby Hashes. + cls.define("params", rackParamsMethod((*rack.Request).Params)) + cls.define("GET", rackParamsMethod((*rack.Request).GET)) + cls.define("POST", rackParamsMethod((*rack.Request).POST)) + cls.define("cookies", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackParamsToHash(self(v).Cookies()) + }) +} + +// rackParamsMethod adapts a (*rack.Request) accessor returning (*Params, error) +// into a native method returning a Ruby Hash, raising on a parse error. +func rackParamsMethod(fn func(*rack.Request) (*rack.Params, error)) NativeFn { + return func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + p, err := fn(v.(*RackRequest).req) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return rackParamsToHash(p) + } +} + +// registerRackResponse installs Rack::Response and its buffering surface. +func (vm *VM) registerRackResponse(mod *RClass) { + cls := newClass("Rack::Response", vm.cObject) + mod.consts["Response"] = cls + vm.consts["Rack::Response"] = cls + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + body := rackResponseBody(args) + status := 200 + if len(args) > 1 { + status = rackInt(args[1], 200) + } + var headers *rack.Headers + if len(args) > 2 { + headers = rackHeadersFrom(args[2]) + } + return &RackResponse{resp: rack.NewResponse(body, status, headers), cls: cls} + }} + + self := func(v object.Value) *rack.Response { return v.(*RackResponse).resp } + + cls.define("write", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + chunk := rackStr(rackArg(args)) + self(v).Write(chunk) + return object.NewString(chunk) + }) + cls.define("status", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(int64(self(v).Status())) + }) + cls.define("status=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetStatus(rackInt(rackArg(args), 200)) + return rackArg(args) + }) + cls.define("headers", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackHeadersToHash(self(v).Headers()) + }) + cls.define("[]", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + return rackFromGo(self(v).GetHeader(rackStr(args[0]))) + }) + cls.define("[]=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + self(v).SetHeader(rackStr(args[0]), rackStr(args[1])) + return args[1] + }) + cls.define("set_header", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + self(v).SetHeader(rackStr(args[0]), rackStr(args[1])) + return args[1] + }) + cls.define("content_type", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).ContentType()) + }) + cls.define("content_type=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetContentType(rackStr(rackArg(args))) + return rackArg(args) + }) + cls.define("location", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).Location()) + }) + cls.define("redirect", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + status := 302 + if len(args) > 1 { + status = rackInt(args[1], 302) + } + self(v).Redirect(rackStr(args[0]), status) + return object.NilV + }) + cls.define("body", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackBodyArray(self(v).Body()) + }) + cls.define("empty?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).Empty()) + }) + cls.define("finish", rackFinishMethod(self)) + cls.define("to_a", rackFinishMethod(self)) + cls.define("redirect?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).IsRedirect()) + }) + cls.define("ok?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).OK()) + }) + cls.define("not_found?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).NotFound()) + }) +} + +// rackFinishMethod returns the [status, headers, body] triple as a Ruby Array, +// the SPEC form (#finish / #to_a). +func rackFinishMethod(self func(object.Value) *rack.Response) NativeFn { + return func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + status, headers, body := self(v).Finish() + return &object.Array{Elems: []object.Value{ + object.Integer(int64(status)), + rackHeadersToHash(headers), + rackBodyArray(body), + }} + } +} + +// registerRackUtils installs the Rack::Utils module of pure encoding/query +// helpers (also the target of require "rack/utils"). +func (vm *VM) registerRackUtils(mod *RClass) { + util := newClass("Rack::Utils", nil) + util.isModule = true + mod.consts["Utils"] = util + vm.consts["Rack::Utils"] = util + + def := func(name string, fn NativeFn) { util.smethods[name] = &Method{name: name, owner: util, native: fn} } + + strFn := func(fn func(string) string) NativeFn { + return func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(fn(rackStr(rackArg(args)))) + } + } + def("escape", strFn(rack.Escape)) + def("escape_path", strFn(rack.EscapePath)) + def("escape_html", strFn(rack.EscapeHTML)) + def("unescape_html", strFn(rack.UnescapeHTML)) + def("unescape_path", strFn(rack.UnescapePath)) + def("unescape", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + s, err := rack.Unescape(rackStr(rackArg(args))) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return object.NewString(s) + }) + def("parse_query", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + p, err := rack.ParseQuery(rackStr(rackArg(args)), "&") + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return rackParamsToHash(p) + }) + def("build_query", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(rack.BuildQuery(rackParamsFromHash(rackArg(args)))) + }) + def("status_code", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.Integer(int64(rackInt(rackArg(args), 500))) + }) +} diff --git a/internal/vm/rack_helpers.go b/internal/vm/rack_helpers.go new file mode 100644 index 00000000..26033160 --- /dev/null +++ b/internal/vm/rack_helpers.go @@ -0,0 +1,243 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strconv" + + rack "github.com/go-ruby-rack/rack" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// rackStr coerces an argument to its String contents: a String yields its bytes, +// a Symbol its name, any other value its to_s. +func rackStr(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + } + return v.ToS() +} + +// rackInt reads an argument as an int, falling back to def for a non-integer. +func rackInt(v object.Value, def int) int { + switch n := v.(type) { + case object.Integer: + return int(n) + case *object.String: + if i, err := strconv.Atoi(n.Str()); err == nil { + return i + } + } + return def +} + +// rackArg reads the single value argument of a method, defaulting to nil. +func rackArg(args []object.Value) object.Value { + if len(args) == 0 { + return object.NilV + } + return args[0] +} + +// rackEnv converts a Ruby Hash into a rack.Env (map[string]any), stringifying +// keys and mapping scalar values into the generic Go value model rack consumes. +func rackEnv(v object.Value) rack.Env { + h, ok := v.(*object.Hash) + if !ok { + raise("TypeError", "no implicit conversion into Hash") + } + env := make(rack.Env, len(h.Keys)) + for _, k := range h.Keys { + val, _ := h.Get(k) + env[rackStr(k)] = rackEnvValue(val) + } + return env +} + +// rackEnvValue maps a Ruby env value into the Go value a rack.Env holds. Env +// entries are almost always strings; scalars are preserved and anything else is +// stringified so the request accessors always see a usable value. +func rackEnvValue(v object.Value) any { + switch n := v.(type) { + case nil, object.Nil: + return nil + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + case object.Bool: + return bool(n) + case object.Integer: + return int64(n) + case object.Float: + return float64(n) + } + return v.ToS() +} + +// rackHeadersFrom builds a *rack.Headers from a Ruby Hash (a nil / non-Hash +// argument yields empty headers), stringifying keys and values. +func rackHeadersFrom(v object.Value) *rack.Headers { + h := rack.NewHeaders() + hash, ok := v.(*object.Hash) + if !ok { + return h + } + for _, k := range hash.Keys { + val, _ := hash.Get(k) + h.Set(rackStr(k), rackStr(val)) + } + return h +} + +// rackResponseBody reads the body argument of Rack::Response.new: a String is a +// one-part body, an Array is its parts (each stringified), and nil/absent is an +// empty body. +func rackResponseBody(args []object.Value) []string { + if len(args) == 0 { + return nil + } + switch n := args[0].(type) { + case object.Nil: + return nil + case *object.String: + return []string{n.Str()} + case *object.Array: + out := make([]string, len(n.Elems)) + for i, el := range n.Elems { + out[i] = rackStr(el) + } + return out + } + return []string{rackStr(args[0])} +} + +// rackBodyArray maps a []string response body into a Ruby Array of Strings. +func rackBodyArray(parts []string) *object.Array { + arr := &object.Array{Elems: make([]object.Value, len(parts))} + for i, p := range parts { + arr.Elems[i] = object.NewString(p) + } + return arr +} + +// rackParamsToHash maps a *rack.Params into a Ruby Hash keyed by String, in +// insertion order. A nil Params yields an empty Hash. +func rackParamsToHash(p *rack.Params) *object.Hash { + h := object.NewHash() + if p == nil { + return h + } + for _, k := range p.Keys() { + val, _ := p.Get(k) + h.Set(object.NewString(k), rackFromGo(val)) + } + return h +} + +// rackParamsFromHash builds a *rack.Params from a Ruby Hash (a non-Hash argument +// yields empty params), for Rack::Utils.build_query. +func rackParamsFromHash(v object.Value) *rack.Params { + p := rack.NewParams() + hash, ok := v.(*object.Hash) + if !ok { + return p + } + for _, k := range hash.Keys { + val, _ := hash.Get(k) + p.Set(rackStr(k), rackToGo(val)) + } + return p +} + +// rackHeadersToHash maps a *rack.Headers into a Ruby Hash keyed by String, in +// key order. +func rackHeadersToHash(h *rack.Headers) *object.Hash { + out := object.NewHash() + if h == nil { + return out + } + for _, k := range h.Keys() { + out.Set(object.NewString(k), rackFromGo(h.Get(k))) + } + return out +} + +// rackFromGo maps a generic Go value (as held by rack Params/Headers) back into +// the rbgo object graph. +func rackFromGo(v any) object.Value { + switch n := v.(type) { + case nil: + return object.NilV + case string: + return object.NewString(n) + case bool: + return object.Bool(n) + case int: + return object.Integer(int64(n)) + case int64: + return object.Integer(n) + case float64: + return object.Float(n) + case []any: + arr := &object.Array{Elems: make([]object.Value, len(n))} + for i, el := range n { + arr.Elems[i] = rackFromGo(el) + } + return arr + case []string: + arr := &object.Array{Elems: make([]object.Value, len(n))} + for i, el := range n { + arr.Elems[i] = object.NewString(el) + } + return arr + case map[string]any: + h := object.NewHash() + for k, val := range n { + h.Set(object.NewString(k), rackFromGo(val)) + } + return h + case *rack.Params: + return rackParamsToHash(n) + } + return object.NilV +} + +// 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 { + switch n := v.(type) { + case nil, object.Nil: + return nil + case object.Bool: + return bool(n) + case object.Integer: + return int64(n) + case object.Float: + return float64(n) + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + case *object.Array: + out := make([]any, len(n.Elems)) + for i, el := range n.Elems { + out[i] = rackToGo(el) + } + return out + case *object.Hash: + m := make(map[string]any, len(n.Keys)) + for _, k := range n.Keys { + val, _ := n.Get(k) + m[rackStr(k)] = rackToGo(val) + } + return m + } + return v.ToS() +} diff --git a/internal/vm/rack_internal_test.go b/internal/vm/rack_internal_test.go new file mode 100644 index 00000000..1cee9a8f --- /dev/null +++ b/internal/vm/rack_internal_test.go @@ -0,0 +1,360 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "reflect" + "testing" + + rack "github.com/go-ruby-rack/rack" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// TestRackWrapperInspect covers ToS / Inspect / Truthy of the Rack value +// wrappers. +func TestRackWrapperInspect(t *testing.T) { + checks := []interface { + ToS() string + Inspect() string + Truthy() bool + }{ + &RackRequest{}, + &RackResponse{}, + } + wantToS := []string{"#", "#"} + for i, c := range checks { + if c.ToS() != wantToS[i] || c.Inspect() != wantToS[i] || !c.Truthy() { + t.Errorf("%T: ToS=%q Inspect=%q Truthy=%v", c, c.ToS(), c.Inspect(), c.Truthy()) + } + } +} + +// TestRackConstants covers the module, RELEASE and the require feature. +func TestRackConstants(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "rack"`, "true\n"}, + {`require "rack"; p require "rack"`, "false\n"}, + {`require "rack"; p Rack.is_a?(Module)`, "true\n"}, + {`require "rack"; puts Rack::RELEASE`, rackRelease + "\n"}, + {`p require "rack/utils"`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRackRequestAccessors covers every string accessor, predicate, port, +// get_header (present/absent), has_header? and the parsed-params methods. +func TestRackRequestAccessors(t *testing.T) { + pre := `require "rack"; r = Rack::Request.new(` + + `"REQUEST_METHOD" => "POST", "PATH_INFO" => "/p", "SCRIPT_NAME" => "/s", ` + + `"QUERY_STRING" => "a=1&b=2", "SERVER_NAME" => "h", "SERVER_PORT" => "80", ` + + `"CONTENT_TYPE" => "text/html", "rack.url_scheme" => "http", "HTTP_HOST" => "h"); ` + cases := []struct{ expr, want string }{ + {"r.request_method", "POST"}, + {"r.path_info", "/p"}, + {"r.script_name", "/s"}, + {"r.query_string", "a=1&b=2"}, + {"r.server_name", "h"}, + {"r.server_port", "80"}, + {"r.content_type", "text/html"}, + {"r.media_type", "text/html"}, + {"r.scheme", "http"}, + {"r.host", "h"}, + {"r.path", "/s/p"}, + {"r.fullpath", "/s/p?a=1&b=2"}, + {"r.post?", "true"}, + {"r.get?", "false"}, + {"r.put?", "false"}, + {"r.patch?", "false"}, + {"r.delete?", "false"}, + {"r.head?", "false"}, + {"r.options?", "false"}, + {"r.xhr?", "false"}, + {"r.ssl?", "false"}, + {"r.port", "80"}, + {`r.get_header("REQUEST_METHOD")`, "POST"}, + {`r.get_header("NOPE").inspect`, "nil"}, + {`r.has_header?("PATH_INFO")`, "true"}, + {`r.has_header?("NOPE")`, "false"}, + {`r.params["a"]`, "1"}, + {`r.GET["b"]`, "2"}, + {`r.POST.length`, "0"}, + {`r.cookies.class`, "Hash"}, + } + for _, c := range cases { + src := pre + "puts (" + c.expr + ")" + if got := eval(t, src); got != c.want+"\n" { + t.Errorf("expr=%q got=%q want=%q", c.expr, got, c.want+"\n") + } + } +} + +// TestRackRequestBaseURLAndIP covers base_url / url / ip, which read a fuller +// env, and Request.new's missing-argument error. +func TestRackRequestBaseURLAndIP(t *testing.T) { + if got := eval(t, `require "rack"; r = Rack::Request.new("rack.url_scheme"=>"http","HTTP_HOST"=>"ex","PATH_INFO"=>"/x"); puts r.base_url; puts r.url`); got != "http://ex\nhttp://ex/x\n" { + t.Errorf("base_url/url got=%q", got) + } + if got := eval(t, `require "rack"; puts Rack::Request.new("REMOTE_ADDR"=>"1.2.3.4").ip`); got != "1.2.3.4\n" { + t.Errorf("ip got=%q", got) + } + class, _ := evalErr(t, `require "rack"; Rack::Request.new`) + if class != "ArgumentError" { + t.Errorf("new no-arg class=%q", class) + } + class, _ = evalErr(t, `require "rack"; Rack::Request.new("QUERY_STRING"=>"a=%ZZ","REQUEST_METHOD"=>"GET").GET`) + if class != "ArgumentError" { + t.Errorf("GET parse-error class=%q", class) + } + class, _ = evalErr(t, `require "rack"; Rack::Request.new("PATH_INFO"=>"/x").get_header`) + if class != "ArgumentError" { + t.Errorf("get_header no-arg class=%q", class) + } + class, _ = evalErr(t, `require "rack"; Rack::Request.new("PATH_INFO"=>"/x").has_header?`) + if class != "ArgumentError" { + t.Errorf("has_header? no-arg class=%q", class) + } +} + +// TestRackResponse covers the full response surface. +func TestRackResponse(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "rack"; res = Rack::Response.new; res.write("hi"); puts res.finish[0]`, "200\n"}, + {`require "rack"; res = Rack::Response.new("body", 201); puts res.status`, "201\n"}, + {`require "rack"; res = Rack::Response.new(["a","b"]); puts res.body.join`, "ab\n"}, + {`require "rack"; res = Rack::Response.new(nil, 200, {"x"=>"y"}); puts res["x"]`, "y\n"}, + {`require "rack"; res = Rack::Response.new; res.status = 404; puts res.status`, "404\n"}, + {`require "rack"; res = Rack::Response.new; res["a"] = "b"; puts res.headers["a"]`, "b\n"}, + {`require "rack"; res = Rack::Response.new; res.set_header("a","b"); puts res["a"]`, "b\n"}, + {`require "rack"; res = Rack::Response.new; res.content_type = "text/css"; puts res.content_type`, "text/css\n"}, + {`require "rack"; res = Rack::Response.new; res.redirect("/here", 301); puts res.status; puts res.location`, "301\n/here\n"}, + {`require "rack"; res = Rack::Response.new; res.redirect("/here"); puts res.status`, "302\n"}, + {`require "rack"; res = Rack::Response.new; puts res.empty?`, "true\n"}, + {`require "rack"; res = Rack::Response.new("x"); puts res.empty?`, "false\n"}, + {`require "rack"; res = Rack::Response.new("x", 302); puts res.redirect?`, "true\n"}, + {`require "rack"; res = Rack::Response.new("x", 200); puts res.ok?`, "true\n"}, + {`require "rack"; res = Rack::Response.new("x", 404); puts res.not_found?`, "true\n"}, + {`require "rack"; res = Rack::Response.new("x", 200, {"c"=>"d"}); a = res.to_a; puts a[0]; puts a[2].join`, "200\nx\n"}, + {`require "rack"; res = Rack::Response.new; puts res.write("z")`, "z\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + // Error arms. + for _, src := range []string{ + `require "rack"; Rack::Response.new[]`, + `require "rack"; Rack::Response.new.[]=("a")`, + `require "rack"; Rack::Response.new.set_header("a")`, + `require "rack"; Rack::Response.new.redirect`, + } { + if class, _ := evalErr(t, src); class != "ArgumentError" { + t.Errorf("src=%q class=%q want ArgumentError", src, class) + } + } +} + +// TestRackUtils covers every Rack::Utils function and its error arms. +func TestRackUtils(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "rack/utils"; puts Rack::Utils.escape("a b")`, "a+b\n"}, + {`require "rack/utils"; puts Rack::Utils.escape_path("a b")`, "a%20b\n"}, + {`require "rack/utils"; puts Rack::Utils.escape_html("")`, "<a>\n"}, + {`require "rack/utils"; puts Rack::Utils.unescape_html("<a>")`, "\n"}, + {`require "rack/utils"; puts Rack::Utils.unescape("a+b")`, "a b\n"}, + {`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.status_code(404)`, "404\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + for _, src := range []string{ + `require "rack/utils"; Rack::Utils.unescape("%ZZ")`, + `require "rack/utils"; Rack::Utils.parse_query("a=%ZZ")`, + } { + if class, _ := evalErr(t, src); class != "ArgumentError" { + t.Errorf("src=%q class=%q want ArgumentError", src, class) + } + } +} + +// TestRackStr covers the String / Symbol / default arms. +func TestRackStr(t *testing.T) { + if rackStr(object.NewString("x")) != "x" || rackStr(object.Symbol("y")) != "y" || rackStr(object.Integer(3)) != "3" { + t.Error("rackStr arms") + } +} + +// TestRackInt covers Integer / valid-String / invalid-String / other arms. +func TestRackInt(t *testing.T) { + if rackInt(object.Integer(7), 0) != 7 { + t.Error("int arm") + } + if rackInt(object.NewString("9"), 0) != 9 { + t.Error("valid string arm") + } + if rackInt(object.NewString("no"), 5) != 5 { + t.Error("invalid string arm") + } + if rackInt(object.NilV, 4) != 4 { + t.Error("other arm") + } +} + +// TestRackArg covers absent / present. +func TestRackArg(t *testing.T) { + if rackArg(nil) != object.NilV { + t.Error("absent") + } + if rackArg([]object.Value{object.Integer(1)}) != object.Integer(1) { + t.Error("present") + } +} + +// TestRackEnvValue covers every arm of the env value converter. +func TestRackEnvValue(t *testing.T) { + checks := []struct { + in object.Value + want any + }{ + {object.NilV, nil}, + {object.NewString("s"), "s"}, + {object.Symbol("y"), "y"}, + {object.Bool(true), true}, + {object.Integer(3), int64(3)}, + {object.Float(1.5), 1.5}, + {&object.Array{}, "[]"}, + } + for _, c := range checks { + if got := rackEnvValue(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("rackEnvValue(%#v)=%#v want %#v", c.in, got, c.want) + } + } +} + +// TestRackEnvTypeError covers rackEnv's non-Hash raise. +func TestRackEnvTypeError(t *testing.T) { + if class, _ := evalErr(t, `require "rack"; Rack::Request.new(1)`); class != "TypeError" { + t.Errorf("class=%q want TypeError", class) + } +} + +// TestRackResponseBody covers absent / Nil / String / Array / default arms. +func TestRackResponseBody(t *testing.T) { + if rackResponseBody(nil) != nil { + t.Error("absent") + } + if rackResponseBody([]object.Value{object.NilV}) != nil { + t.Error("nil arm") + } + if got := rackResponseBody([]object.Value{object.NewString("x")}); !reflect.DeepEqual(got, []string{"x"}) { + t.Errorf("string arm got=%#v", got) + } + if got := rackResponseBody([]object.Value{&object.Array{Elems: []object.Value{object.NewString("a"), object.Integer(2)}}}); !reflect.DeepEqual(got, []string{"a", "2"}) { + t.Errorf("array arm got=%#v", got) + } + if got := rackResponseBody([]object.Value{object.Integer(9)}); !reflect.DeepEqual(got, []string{"9"}) { + t.Errorf("default arm got=%#v", got) + } +} + +// TestRackHeadersFrom covers the Hash and non-Hash arms. +func TestRackHeadersFrom(t *testing.T) { + if h := rackHeadersFrom(object.NilV); h.Len() != 0 { + t.Error("non-Hash arm") + } + hash := object.NewHash() + hash.Set(object.NewString("A"), object.NewString("b")) + if h := rackHeadersFrom(hash); h.Get("a") != "b" { + t.Errorf("hash arm got=%v", h.Get("a")) + } +} + +// TestRackParamsFromHash covers the Hash and non-Hash arms. +func TestRackParamsFromHash(t *testing.T) { + if p := rackParamsFromHash(object.Integer(1)); p.Len() != 0 { + t.Error("non-Hash arm") + } + hash := object.NewHash() + hash.Set(object.NewString("k"), object.NewString("v")) + if p := rackParamsFromHash(hash); rack.BuildQuery(p) != "k=v" { + t.Errorf("hash arm got=%q", rack.BuildQuery(p)) + } +} + +// TestRackParamsAndHeadersNil covers the nil-guard arms. +func TestRackParamsAndHeadersNil(t *testing.T) { + if rackParamsToHash(nil).Len() != 0 { + t.Error("nil params") + } + if rackHeadersToHash(nil).Len() != 0 { + t.Error("nil headers") + } +} + +// TestRackFromGo covers every arm of the Go->object converter. +func TestRackFromGo(t *testing.T) { + params := rack.NewParams() + params.Set("k", "v") + checks := []struct { + in any + want string + }{ + {nil, "nil"}, + {"s", `"s"`}, + {true, "true"}, + {int(3), "3"}, + {int64(4), "4"}, + {1.5, "1.5"}, + {[]any{"a", 1}, `["a", 1]`}, + {[]string{"x", "y"}, `["x", "y"]`}, + {map[string]any{"k": "v"}, `{"k" => "v"}`}, + {params, `{"k" => "v"}`}, + {struct{}{}, "nil"}, + } + for _, c := range checks { + if got := rackFromGo(c.in).Inspect(); got != c.want { + t.Errorf("rackFromGo(%#v)=%q want %q", c.in, got, c.want) + } + } +} + +// TestRackToGo covers every arm of the object->Go converter. +func TestRackToGo(t *testing.T) { + arr := &object.Array{Elems: []object.Value{object.Integer(1)}} + h := object.NewHash() + h.Set(object.NewString("k"), object.Integer(2)) + checks := []struct { + in object.Value + want any + }{ + {object.NilV, nil}, + {object.Bool(true), true}, + {object.Integer(5), int64(5)}, + {object.Float(2.5), 2.5}, + {object.NewString("s"), "s"}, + {object.Symbol("y"), "y"}, + {arr, []any{int64(1)}}, + {h, map[string]any{"k": int64(2)}}, + } + for _, c := range checks { + if got := rackToGo(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("rackToGo(%#v)=%#v want %#v", c.in, got, c.want) + } + } + // default arm: a wrapper value falls back to its to_s. + if got := rackToGo(&RackResponse{}); got != "#" { + t.Errorf("default arm got=%#v", got) + } +} diff --git a/internal/vm/require.go b/internal/vm/require.go index 08b1308a..46d20137 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -51,6 +51,8 @@ var providedFeatures = map[string]bool{ "rspec": true, "rspec/expectations": true, "rspec/matchers": true, "rubocop": true, "grape": true, + "rack": true, "rack/utils": true, + "sinatra": true, "sinatra/base": true, "active_record": true, "activerecord": true, "public_suffix": true, "mime/types": true, "mail": true, "faker": true, "rqrcode": true, "dotenv": true, "hcl2": true, "kramdown": true, diff --git a/internal/vm/sinatra.go b/internal/vm/sinatra.go new file mode 100644 index 00000000..fe247a15 --- /dev/null +++ b/internal/vm/sinatra.go @@ -0,0 +1,151 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + sinatra "github.com/go-ruby-sinatra/sinatra" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// sinatraDef is the per-class declaration a Sinatra::Base subclass accumulates +// as its body runs the class-level DSL (get/post/…, before/after, not_found, +// error, set/enable/disable). Each subclass owns one; App.new.call(env) turns it +// (and its ancestors') into a live github.com/go-ruby-sinatra app to serve the +// request. The route/filter handler is the Ruby block captured verbatim; running +// it against a request context is the rbgo seam. +type sinatraDef struct { + routes []sinatraRoute + befores []sinatraFilter + afters []sinatraFilter + notFound *Proc + errors map[int]*Proc + settings map[string]object.Value +} + +// sinatraRoute is one class-DSL route: an HTTP verb, a Mustermann pattern and +// the handler block. +type sinatraRoute struct { + verb string + pattern string + blk *Proc +} + +// sinatraFilter is one before/after filter: an optional pattern ("" = every +// request) and the block. +type sinatraFilter struct { + pattern string + blk *Proc +} + +// SinatraCtx is the self a route/filter block runs against — the request-scoped +// Sinatra helper surface (params, request, response, status, body, headers, +// content_type, redirect, halt, pass, session, settings). It wraps the +// library's per-request *sinatra.Context; the block is instance_eval'd with this +// as self so a bare `params` / `halt` resolves here. +type SinatraCtx struct { + c *sinatra.Context + cls *RClass + settings map[string]object.Value // the app's merged set/enable/disable values, for the `settings` helper +} + +func (c *SinatraCtx) ToS() string { return "#" } +func (c *SinatraCtx) Inspect() string { return c.ToS() } +func (c *SinatraCtx) Truthy() bool { return true } + +// SinatraSettings is the read view a handler's `settings` helper returns: it +// exposes the class-DSL set/enable/disable values via #[] and method_missing so +// `settings.foo` / `settings.foo?` read what the app declared. +type SinatraSettings struct { + settings map[string]object.Value + cls *RClass +} + +func (s *SinatraSettings) ToS() string { return "#" } +func (s *SinatraSettings) Inspect() string { return s.ToS() } +func (s *SinatraSettings) Truthy() bool { return true } + +// sinatraDefFor returns the sinatraDef for a Sinatra::Base subclass, creating it +// on first use. Definitions are keyed by the class object, so each subclass keeps +// its own route table (and ancestors are walked at dispatch time). +func (vm *VM) sinatraDefFor(cls *RClass) *sinatraDef { + if vm.sinatraDefs == nil { + vm.sinatraDefs = map[*RClass]*sinatraDef{} + } + d, ok := vm.sinatraDefs[cls] + if !ok { + d = &sinatraDef{errors: map[int]*Proc{}, settings: map[string]object.Value{}} + vm.sinatraDefs[cls] = d + } + return d +} + +// sinatraChain returns cls and its ancestors up to (and excluding) Sinatra::Base, +// outermost-ancestor first, so a subclass inherits its parents' routes/filters +// (parents declared earlier win, matching Ruby's method-declaration order). +func (vm *VM) sinatraChain(cls *RClass) []*sinatraDef { + var classes []*RClass + for c := cls; c != nil && c != vm.cSinatraBase; c = c.super { + classes = append(classes, c) + } + // Reverse so the outermost ancestor's routes register first. + defs := make([]*sinatraDef, 0, len(classes)) + for i := len(classes) - 1; i >= 0; i-- { + if d, ok := vm.sinatraDefs[classes[i]]; ok { + defs = append(defs, d) + } + } + return defs +} + +// registerSinatra installs the Sinatra module and Sinatra::Base (require +// "sinatra/base" / "sinatra"): the class-level routing DSL (get/post/put/delete/ +// patch/options/head), the before/after filters, not_found / error handlers and +// set/enable/disable settings, plus the instance #call(env) Rack adapter that +// dispatches a request through the go-ruby-sinatra router and returns the SPEC +// [status, headers, body] triple. Route handler blocks run against a SinatraCtx +// (params/request/response/status/body/headers/content_type/redirect/halt/pass); +// their evaluation is the rbgo seam, the routing/params/dispatch is the library. +func (vm *VM) registerSinatra() { + mod := newClass("Sinatra", nil) + mod.isModule = true + vm.consts["Sinatra"] = mod + + base := newClass("Sinatra::Base", vm.cObject) + mod.consts["Base"] = base + vm.consts["Sinatra::Base"] = base + vm.cSinatraBase = base + + ctx := newClass("Sinatra::Base::Context", vm.cObject) + base.consts["Context"] = ctx + vm.cSinatraCtx = ctx + + settings := newClass("Sinatra::Base::Settings", vm.cObject) + base.consts["Settings"] = settings + vm.cSinatraSettings = settings + + vm.registerSinatraDSL(base) + vm.registerSinatraContext(ctx) + vm.registerSinatraSettings(settings) + + // Sinatra::NotFound < StandardError, raised/checked for an unmatched route + // (Sinatra maps it to a 404). Provided so `require "sinatra/base"` exposes the + // name application code rescues. + std := vm.consts["StandardError"].(*RClass) + nf := newClass("Sinatra::NotFound", std) + mod.consts["NotFound"] = nf + vm.consts["Sinatra::NotFound"] = nf +} + +// sinatraStr coerces an argument to its String contents. +func sinatraStr(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + } + return v.ToS() +} diff --git a/internal/vm/sinatra_bind.go b/internal/vm/sinatra_bind.go new file mode 100644 index 00000000..976e1538 --- /dev/null +++ b/internal/vm/sinatra_bind.go @@ -0,0 +1,312 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + sinatra "github.com/go-ruby-sinatra/sinatra" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires the Sinatra::Base class-level DSL and the instance #call Rack +// adapter over github.com/go-ruby-sinatra. The class DSL records routes/filters/ +// handlers into a per-class sinatraDef; #call builds a live sinatra app from the +// class (and its ancestors), serves the Rack env and returns the SPEC triple. +// Each route handler is the captured Ruby block, instance_eval'd against a +// SinatraCtx — the block eval is the rbgo seam, the routing/params/dispatch is +// the library. + +// registerSinatraDSL installs the class-level routing DSL on Sinatra::Base +// (inherited by every subclass) plus the instance/class #call adapter. +func (vm *VM) registerSinatraDSL(base *RClass) { + // Verb methods: get/post/put/delete/patch/options/head "pattern" do … end. + verb := func(name string) NativeFn { + return func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + d := vm.sinatraDefFor(sinatraClassOf(self)) + d.routes = append(d.routes, sinatraRoute{verb: name, pattern: sinatraPattern(args), blk: blk}) + return object.NilV + } + } + for _, m := range []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"} { + base.smethods[sinatraVerbName(m)] = &Method{name: sinatraVerbName(m), owner: base, native: verb(m)} + } + + // Filters: before / after [pattern] do … end. + filter := func(after bool) NativeFn { + return func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + d := vm.sinatraDefFor(sinatraClassOf(self)) + f := sinatraFilter{pattern: sinatraOptPattern(args), blk: blk} + if after { + d.afters = append(d.afters, f) + } else { + d.befores = append(d.befores, f) + } + return object.NilV + } + } + base.smethods["before"] = &Method{name: "before", owner: base, native: filter(false)} + base.smethods["after"] = &Method{name: "after", owner: base, native: filter(true)} + + // not_found do … end. + base.smethods["not_found"] = &Method{name: "not_found", owner: base, native: func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { + vm.sinatraDefFor(sinatraClassOf(self)).notFound = blk + return object.NilV + }} + + // error(status = 500) do … end — a bare error registers the 500 handler. + base.smethods["error"] = &Method{name: "error", owner: base, native: func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + code := 500 + if len(args) > 0 { + code = sinatraInt(args[0], 500) + } + vm.sinatraDefFor(sinatraClassOf(self)).errors[code] = blk + return object.NilV + }} + + // set(key, value) / enable(key) / disable(key). + base.smethods["set"] = &Method{name: "set", owner: base, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + vm.sinatraDefFor(sinatraClassOf(self)).settings[sinatraStr(args[0])] = args[1] + return object.NilV + }} + toggle := func(on bool) NativeFn { + return func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := vm.sinatraDefFor(sinatraClassOf(self)) + for _, a := range args { + d.settings[sinatraStr(a)] = object.Bool(on) + } + return object.NilV + } + } + base.smethods["enable"] = &Method{name: "enable", owner: base, native: toggle(true)} + base.smethods["disable"] = &Method{name: "disable", owner: base, native: toggle(false)} + + // configure { … } runs its block against the class, so a `set`/`enable` inside + // registers on the class exactly like a top-level call (env filtering, which + // Sinatra applies, is a no-op here — the block always runs). + base.smethods["configure"] = &Method{name: "configure", owner: base, native: func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { + if blk != nil { + vm.callBlockSelf(blk, self, nil) + } + return object.NilV + }} + + // helpers { def foo; …; end } grafts the block's method definitions onto the + // request-context class, so every handler can call them (Sinatra's helpers). + base.smethods["helpers"] = &Method{name: "helpers", owner: base, native: func(vm *VM, _ object.Value, _ []object.Value, blk *Proc) object.Value { + if blk != nil { + vm.classEval(vm.cSinatraCtx, blk, nil) + } + return object.NilV + }} + + // #call(env) — the Rack adapter, available both as an instance method + // (App.new.call(env)) and a class method (App.call(env)). + callFn := func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.sinatraCall(sinatraClassOf(self), rackArg(args)) + } + base.methods["call"] = &Method{name: "call", owner: base, native: callFn} + base.methods["call!"] = &Method{name: "call!", owner: base, native: callFn} + base.smethods["call"] = &Method{name: "call", owner: base, native: callFn} +} + +// sinatraCall builds the live sinatra app for cls, serves the Rack env and +// returns the SPEC [status, headers, body] triple as a Ruby Array. +func (vm *VM) sinatraCall(cls *RClass, envArg object.Value) object.Value { + app := vm.buildSinatraApp(cls) + status, headers, body := app.CallTuple(rackEnv(envArg)) + return &object.Array{Elems: []object.Value{ + object.Integer(int64(status)), + rackHeadersToHash(headers), + rackBodyArray(body), + }} +} + +// buildSinatraApp assembles a *sinatra.Sinatra from cls's declaration chain: it +// applies settings (ancestor first, subclass last so a subclass overrides), +// registers routes/filters and the not_found / error handlers, each backed by an +// Action that instance_evals the captured Ruby block against a SinatraCtx. +func (vm *VM) buildSinatraApp(cls *RClass) *sinatra.Sinatra { + app := sinatra.New() + merged := map[string]object.Value{} + defs := vm.sinatraChain(cls) + for _, d := range defs { + for k, v := range d.settings { + merged[k] = v + } + } + for k, v := range merged { + app.Settings().Set(k, sinatraSettingValue(v)) + } + for _, d := range defs { + for _, r := range d.routes { + vm.sinatraRegisterRoute(app, r, merged) + } + for _, f := range d.befores { + app.Before(f.pattern, vm.sinatraAction(f.blk, merged)) + } + for _, f := range d.afters { + app.After(f.pattern, vm.sinatraAction(f.blk, merged)) + } + if d.notFound != nil { + app.NotFound(vm.sinatraAction(d.notFound, merged)) + } + for code, blk := range d.errors { + app.Error(code, vm.sinatraAction(blk, merged)) + } + } + return app +} + +// sinatraRegisterRoute registers one route on the app for its verb. +func (vm *VM) sinatraRegisterRoute(app *sinatra.Sinatra, r sinatraRoute, merged map[string]object.Value) { + action := vm.sinatraAction(r.blk, merged) + switch r.verb { + case "GET": + app.Get(r.pattern, action) + case "POST": + app.Post(r.pattern, action) + case "PUT": + app.Put(r.pattern, action) + case "DELETE": + app.Delete(r.pattern, action) + case "PATCH": + app.Patch(r.pattern, action) + case "OPTIONS": + app.Options(r.pattern, action) + case "HEAD": + app.Head(r.pattern, action) + } +} + +// sinatraAction turns a captured Ruby block into a sinatra.Action: it builds a +// per-request SinatraCtx and instance_evals the block against it, mapping the +// block's return value into Sinatra's body coercion. halt/redirect/pass unwind +// via the library's panic-based control flow, which the dispatcher recovers. +func (vm *VM) sinatraAction(blk *Proc, merged map[string]object.Value) sinatra.Action { + return func(c *sinatra.Context) any { + ctx := &SinatraCtx{c: c, cls: vm.cSinatraCtx} + ctx.settings = merged + if blk == nil { + return nil + } + return sinatraResult(vm, vm.callBlockSelf(blk, ctx, nil)) + } +} + +// registerSinatraContext installs the request-context helper surface a handler +// block runs against (self = SinatraCtx). +func (vm *VM) registerSinatraContext(ctx *RClass) { + self := func(v object.Value) *SinatraCtx { return v.(*SinatraCtx) } + + ctx.define("params", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return sinatraParamsHash(self(v).c) + }) + ctx.define("request", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return &RackRequest{req: self(v).c.Request(), cls: vm.consts["Rack::Request"].(*RClass)} + }) + ctx.define("response", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return &RackResponse{resp: self(v).c.Response(), cls: vm.consts["Rack::Response"].(*RClass)} + }) + ctx.define("session", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + // The session seam is the rack.session env value, which lives in the Go + // value model (the env was converted at #call); map it back for Ruby. A + // missing session reads back as nil. + return rackFromGo(self(v).c.Session()) + }) + ctx.define("settings", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return &SinatraSettings{settings: self(v).settings, cls: vm.cSinatraSettings} + }) + + // status — reader with no arg, setter with one. + ctx.define("status", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + c := self(v).c + if len(args) == 0 { + return object.Integer(int64(c.CurrentStatus())) + } + c.Status(sinatraInt(args[0], 200)) + return object.NilV + }) + ctx.define("body", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + c := self(v).c + if len(args) == 0 { + // Reader form: the currently buffered body as a Rack body Array. + return rackBodyArray(c.Response().Body()) + } + c.Body(sinatraStr(args[0])) + return args[0] + }) + ctx.define("headers", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) > 0 { + if h, ok := args[0].(*object.Hash); ok { + c := self(v).c + for _, k := range h.Keys { + val, _ := h.Get(k) + c.Header(sinatraStr(k), sinatraStr(val)) + } + } + } + return rackHeadersToHash(self(v).c.Response().Headers()) + }) + ctx.define("content_type", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + sinatraSetContentType(self(v).c, args) + return object.NilV + }) + ctx.define("redirect", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + status := []int{} + if len(args) > 1 { + status = append(status, sinatraInt(args[1], 302)) + } + self(v).c.Redirect(sinatraStr(args[0]), status...) + return object.NilV + }) + ctx.define("halt", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).c.Halt(sinatraHaltArgs(args)...) + return object.NilV + }) + ctx.define("pass", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + self(v).c.Pass() + return object.NilV + }) + 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)))) + }) + 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)))) + }) +} + +// registerSinatraSettings installs the read view returned by the `settings` +// helper: #[] and method_missing read the class-DSL set/enable/disable values. +func (vm *VM) registerSinatraSettings(cls *RClass) { + get := func(s *SinatraSettings, key string) object.Value { + if v, ok := s.settings[key]; ok { + return v + } + return object.NilV + } + cls.define("[]", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + return get(v.(*SinatraSettings), sinatraStr(rackArg(args))) + }) + cls.define("respond_to?", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + _, ok := v.(*SinatraSettings).settings[sinatraStr(rackArg(args))] + return object.Bool(ok) + }) + cls.define("method_missing", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + s := v.(*SinatraSettings) + name := sinatraStr(rackArg(args)) + if n := len(name); n > 0 && name[n-1] == '?' { + key := name[:n-1] + val, ok := s.settings[key] + return object.Bool(ok && val.Truthy()) + } + return get(s, name) + }) +} diff --git a/internal/vm/sinatra_helpers.go b/internal/vm/sinatra_helpers.go new file mode 100644 index 00000000..ea8a87eb --- /dev/null +++ b/internal/vm/sinatra_helpers.go @@ -0,0 +1,171 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strconv" + "strings" + + sinatra "github.com/go-ruby-sinatra/sinatra" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// sinatraClassOf resolves the Sinatra::Base subclass a DSL/#call invocation +// targets: the receiver itself when the DSL runs in a class body (self = the +// class), or the receiver's class when #call runs on an instance (App.new.call). +func sinatraClassOf(self object.Value) *RClass { + switch v := self.(type) { + case *RClass: + return v + case *RObject: + return v.class + } + raise("TypeError", "not a Sinatra::Base") + return nil +} + +// sinatraVerbName maps an HTTP method to its lower-case DSL method name. +func sinatraVerbName(method string) string { return strings.ToLower(method) } + +// sinatraPattern reads a route pattern argument, defaulting to "/" (Sinatra's +// empty-path default). +func sinatraPattern(args []object.Value) string { + if len(args) == 0 { + return "/" + } + return sinatraStr(args[0]) +} + +// sinatraOptPattern reads an optional filter pattern: absent yields "" (run on +// every request), matching before/after with no argument. +func sinatraOptPattern(args []object.Value) string { + if len(args) == 0 { + return "" + } + return sinatraStr(args[0]) +} + +// sinatraInt reads an argument as an int, falling back to def. +func sinatraInt(v object.Value, def int) int { + switch n := v.(type) { + case object.Integer: + return int(n) + case *object.String: + if i, err := strconv.Atoi(n.Str()); err == nil { + return i + } + } + return def +} + +// sinatraParamsHash snapshots the context params as a Ruby Hash keyed by String, +// preserving Sinatra's deterministic ordering. Scalar values are Strings; splat/ +// captures are Arrays of Strings. +func sinatraParamsHash(c *sinatra.Context) *object.Hash { + h := object.NewHash() + for _, p := range c.Params() { + h.Set(object.NewString(p.Key), rackFromGo(p.Value)) + } + return h +} + +// sinatraHaltArgs maps the arguments of a `halt` call into the []any the library +// consumes: an Integer sets the status, a String the body, an Array of Strings +// the body parts; anything else is ignored (Sinatra's halt is lenient). +func sinatraHaltArgs(args []object.Value) []any { + out := make([]any, 0, len(args)) + for _, a := range args { + switch n := a.(type) { + case object.Integer: + out = append(out, int(n)) + case *object.String: + out = append(out, n.Str()) + case *object.Array: + parts := make([]string, len(n.Elems)) + for i, el := range n.Elems { + parts[i] = sinatraStr(el) + } + out = append(out, parts) + } + } + return out +} + +// sinatraSetContentType applies a `content_type` call: the first argument is the +// type (a Symbol such as :json, an extension, or a full media type); a trailing +// options Hash may carry charset:. An unknown media type raises, mirroring +// Sinatra — the library signals it by panicking *sinatra.UnknownMediaType, which +// this recovers and re-raises as a Ruby error (control-flow halts still unwind). +func sinatraSetContentType(c *sinatra.Context, args []object.Value) { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + typeArg := sinatraStr(args[0]) + charset := "" + if len(args) > 1 { + if h, ok := args[len(args)-1].(*object.Hash); ok { + if v, ok := h.Get(object.Symbol("charset")); ok { + charset = sinatraStr(v) + } + } + } + // ContentTypeCharset's only failure mode is a panic of *UnknownMediaType (an + // unrecognised type), which is turned into a Ruby raise so application code + // sees the error exactly as under Sinatra rather than an aborted process. + defer func() { + if r := recover(); r != nil { + raise("RuntimeError", "%v", r) + } + }() + c.ContentTypeCharset(typeArg, charset) +} + +// sinatraSettingValue maps a Ruby settings value into the Go value the library's +// Settings registry holds (string / bool / int, else its to_s). +func sinatraSettingValue(v object.Value) any { + switch n := v.(type) { + case object.Bool: + return bool(n) + case object.Integer: + return int(n) + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + } + return v.ToS() +} + +// sinatraResult maps a route/filter block's return value into Sinatra's body +// coercion: a String is the body, an Integer a status, an Array of Strings the +// body parts, nil leaves the body as helpers set it; any other value is rendered +// through its Ruby #to_s. +func sinatraResult(vm *VM, ret object.Value) any { + switch n := ret.(type) { + case nil, object.Nil: + return nil + case *object.String: + return n.Str() + case object.Integer: + return int(n) + case *object.Array: + parts := make([]string, len(n.Elems)) + for i, el := range n.Elems { + parts[i] = sinatraToS(vm, el) + } + return parts + } + return sinatraToS(vm, ret) +} + +// sinatraToS renders a value through Ruby #to_s (so a custom object's body is +// what the app would render), used verbatim for a String. +func sinatraToS(vm *VM, v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + return vm.send(v, "to_s", nil, nil).ToS() +} diff --git a/internal/vm/sinatra_internal_test.go b/internal/vm/sinatra_internal_test.go new file mode 100644 index 00000000..52e0fe06 --- /dev/null +++ b/internal/vm/sinatra_internal_test.go @@ -0,0 +1,499 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "testing" + + rack "github.com/go-ruby-rack/rack" + sinatra "github.com/go-ruby-sinatra/sinatra" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// TestSinatraWrapperInspect covers ToS / Inspect / Truthy of the Sinatra value +// wrappers. +func TestSinatraWrapperInspect(t *testing.T) { + checks := []interface { + ToS() string + Inspect() string + Truthy() bool + }{ + &SinatraCtx{}, + &SinatraSettings{}, + } + wantToS := []string{"#", "#"} + for i, c := range checks { + if c.ToS() != wantToS[i] || c.Inspect() != wantToS[i] || !c.Truthy() { + t.Errorf("%T: ToS=%q Inspect=%q Truthy=%v", c, c.ToS(), c.Inspect(), c.Truthy()) + } + } +} + +// TestSinatraConstants covers the module/class surface and require features. +func TestSinatraConstants(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "sinatra/base"`, "true\n"}, + {`p require "sinatra"`, "true\n"}, + {`require "sinatra/base"; p require "sinatra/base"`, "false\n"}, + {`require "sinatra/base"; p Sinatra.is_a?(Module)`, "true\n"}, + {`require "sinatra/base"; p Sinatra::Base.class`, "Class\n"}, + {`require "sinatra/base"; p Sinatra::NotFound < StandardError`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestSinatraStage3 is the run-conformance headline: a classic Sinatra::Base app +// answered through its Rack #call adapter must yield [200, headers, ["hi amy"]]. +func TestSinatraStage3(t *testing.T) { + src := `require "sinatra/base" +class App < Sinatra::Base + get "/hi" do + "hi #{params['n']}" + end +end +s, h, b = App.new.call("PATH_INFO"=>"/hi","QUERY_STRING"=>"n=amy","REQUEST_METHOD"=>"GET") +puts s +puts b.join +puts h.class` + if got := eval(t, src); got != "200\nhi amy\nHash\n" { + t.Errorf("stage3 got=%q", got) + } +} + +// TestSinatraVerbs covers every verb of the class DSL (get also serves head) via +// the class-method #call form. +func TestSinatraVerbs(t *testing.T) { + base := `require "sinatra/base" +class V < Sinatra::Base + get("/g"){ "g" } + post("/p"){ "p" } + put("/u"){ "u" } + delete("/d"){ "d" } + patch("/a"){ "a" } + options("/o"){ "o" } + head("/h"){ "h" } +end +` + cases := []struct{ method, path, want string }{ + {"GET", "/g", "g"}, + {"POST", "/p", "p"}, + {"PUT", "/u", "u"}, + {"DELETE", "/d", "d"}, + {"PATCH", "/a", "a"}, + {"OPTIONS", "/o", "o"}, + {"HEAD", "/h", "h"}, // CallTuple returns the body; a Rack server strips it for HEAD + } + for _, c := range cases { + src := base + `s, _, b = V.call("REQUEST_METHOD"=>"` + c.method + `","PATH_INFO"=>"` + c.path + `"); puts s; puts b.join` + want := "200\n" + c.want + "\n" + if got := eval(t, src); got != want { + t.Errorf("%s %s got=%q want=%q", c.method, c.path, got, want) + } + } +} + +// TestSinatraHelpers covers filters, halt, redirect, status, content_type, body, +// headers, request/response/session/settings and not_found/error handlers. +func TestSinatraHelpers(t *testing.T) { + cases := []struct{ src, want string }{ + // before filter mutates params; route reads it. + {`require "sinatra/base" +class A < Sinatra::Base + before { @seen = "y" } + get("/x"){ "b#{params['q']}" } + after("/x"){ response } +end +_, _, b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x","QUERY_STRING"=>"q=1") +puts b.join`, "b1\n"}, + // status helper (reader + setter) and body helper. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ status 201; body "made"; status } +end +s,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts s; puts b.join`, "201\nmade\n"}, + // halt with status + body. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ halt 403, "no" } +end +s,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts s; puts b.join`, "403\nno\n"}, + // redirect sets status 303 + an absolute Location (resolved against the + // request base URL, as Sinatra does). + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ redirect "/y", 303 } +end +s,h,_ = A.new.call("rack.url_scheme"=>"http","HTTP_HOST"=>"ex","REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts s; puts h["location"]`, "303\nhttp://ex/y\n"}, + // content_type :json. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ content_type :json; "{}" } +end +_,h,_ = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts h["content-type"]`, "application/json\n"}, + // content_type with an explicit charset: keyword. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ content_type "text/plain", charset: "iso-8859-1"; "x" } +end +_,h,_ = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts h["content-type"]`, "text/plain;charset=iso-8859-1\n"}, + // body reader form returns the buffered body. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ body "buf"; body.join } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "buf\n"}, + // headers helper sets response headers. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ headers "X-A" => "1"; "ok" } +end +_,h,_ = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts h["x-a"]`, "1\n"}, // rack Headers are lower-cased + // request accessor. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ request.request_method } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "GET\n"}, + // not_found handler. + {`require "sinatra/base" +class A < Sinatra::Base + not_found { "missing" } +end +s,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/nope") +puts s; puts b.join`, "404\nmissing\n"}, + // error handler for a bare status returned by a route. + {`require "sinatra/base" +class A < Sinatra::Base + error(500) { "boom" } + get("/x"){ 500 } +end +s,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts s; puts b.join`, "500\nboom\n"}, + // pass falls through to the next matching route. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ pass } + get("/x"){ "second" } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "second\n"}, + // session helper (nil when no rack.session). + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ session.inspect } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "nil\n"}, + // session helper returns the injected rack.session value. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ session } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x","rack.session"=>"tok") +puts b.join`, "tok\n"}, + // uri / url helpers. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ [uri("/a"), url("//h/b")].join(" ") } +end +_,_,b = A.new.call("rack.url_scheme"=>"http","HTTP_HOST"=>"ex","REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "http://ex/a //h/b\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestSinatraSettings covers set/enable/disable, configure, the settings view +// (#[], respond_to?, method_missing with and without ?), and the default +// content type they drive. +func TestSinatraSettings(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "sinatra/base" +class A < Sinatra::Base + set :name, "app" + enable :logging + disable :sessions + get("/x"){ "#{settings[:name]} #{settings.logging?} #{settings.sessions?} #{settings.name}" } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "app true false app\n"}, + {`require "sinatra/base" +class A < Sinatra::Base + configure { set :env, "test" } + get("/x"){ "#{settings.respond_to?(:env)} #{settings.env} #{settings.respond_to?(:nope)} #{settings.nope.inspect}" } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "true test false nil\n"}, + // set default_content_type drives the response content type. + {`require "sinatra/base" +class A < Sinatra::Base + set "default_content_type", "text/plain" + get("/x"){ "hi" } +end +_,h,_ = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts h["content-type"]`, "text/plain;charset=utf-8\n"}, // Sinatra appends the default charset to text/* + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestSinatraHelpersMethod covers helpers { def … } grafting a method onto the +// request context. +func TestSinatraHelpersMethod(t *testing.T) { + src := `require "sinatra/base" +class A < Sinatra::Base + helpers do + def shout(s); s.upcase; end + end + get("/x"){ shout("hi") } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join` + if got := eval(t, src); got != "HI\n" { + t.Errorf("helpers got=%q", got) + } +} + +// TestSinatraInheritance covers a subclass inheriting a parent's routes. +func TestSinatraInheritance(t *testing.T) { + src := `require "sinatra/base" +class Parent < Sinatra::Base + get("/base"){ "from-parent" } +end +class Child < Parent + get("/own"){ "from-child" } +end +_,_,b1 = Child.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/base") +_,_,b2 = Child.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/own") +puts b1.join; puts b2.join` + if got := eval(t, src); got != "from-parent\nfrom-child\n" { + t.Errorf("inheritance got=%q", got) + } +} + +// TestSinatraErrors covers the error arms: an unknown content_type raises, a bad +// env raises, and a route returning an unhandled type is coerced via to_s. +func TestSinatraErrors(t *testing.T) { + // Unknown media type -> RuntimeError surfaced from #call. + class, _ := evalErr(t, `require "sinatra/base" +class A < Sinatra::Base + get("/x"){ content_type :nope; "x" } +end +A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x")`) + if class != "RuntimeError" { + t.Errorf("unknown content_type class=%q want RuntimeError", class) + } + // content_type with no argument. + class, _ = evalErr(t, `require "sinatra/base" +class A < Sinatra::Base + get("/x"){ content_type } +end +A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x")`) + if class != "ArgumentError" { + t.Errorf("content_type no-arg class=%q", class) + } + // redirect with no argument. + class, _ = evalErr(t, `require "sinatra/base" +class A < Sinatra::Base + get("/x"){ redirect } +end +A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x")`) + if class != "ArgumentError" { + t.Errorf("redirect no-arg class=%q", class) + } + // set with too few arguments. + class, _ = evalErr(t, `require "sinatra/base" +class A < Sinatra::Base + set :only +end`) + if class != "ArgumentError" { + t.Errorf("set one-arg class=%q", class) + } +} + +// TestSinatraReturnCoercion covers sinatraResult's arms: an Integer return is a +// status, an Array of strings is the body parts, a non-string is to_s'd, and a +// route whose body is set by a helper (nil return) keeps that body. +func TestSinatraReturnCoercion(t *testing.T) { + cases := []struct{ src, want string }{ + // Integer return -> status only, empty body. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ 204 } +end +s,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts s; puts b.join.length`, "204\n0\n"}, + // Array of strings -> body parts. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ ["a","b"] } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "ab\n"}, + // Non-string, non-array -> to_s. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ 42.5 } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "42.5\n"}, + // nil return with body set by helper. + {`require "sinatra/base" +class A < Sinatra::Base + get("/x"){ body "set"; nil } +end +_,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts b.join`, "set\n"}, + // empty route block -> nil action, empty body. + {`require "sinatra/base" +class A < Sinatra::Base + get "/x" +end +s,_,b = A.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts s; puts b.join.length`, "200\n0\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestSinatraClassOf covers the RClass, RObject and default (raise) arms. +func TestSinatraClassOf(t *testing.T) { + c := newClass("K", nil) + if sinatraClassOf(c) != c { + t.Error("RClass arm") + } + obj := &RObject{class: c} + if sinatraClassOf(obj) != c { + t.Error("RObject arm") + } + defer func() { + r := recover() + if r == nil { + t.Fatal("default arm: expected a raise") + } + if re, ok := r.(RubyError); !ok || re.Class != "TypeError" { + t.Errorf("default arm: got %#v", r) + } + }() + sinatraClassOf(object.Integer(1)) +} + +// TestSinatraPatternHelpers covers the pattern/optional-pattern defaults and +// sinatraInt / sinatraVerbName / sinatraSettingValue arms directly. +func TestSinatraPatternHelpers(t *testing.T) { + if sinatraPattern(nil) != "/" || sinatraPattern([]object.Value{object.NewString("/p")}) != "/p" { + t.Error("sinatraPattern") + } + if sinatraOptPattern(nil) != "" || sinatraOptPattern([]object.Value{object.NewString("/f")}) != "/f" { + t.Error("sinatraOptPattern") + } + if sinatraInt(object.Integer(3), 0) != 3 || sinatraInt(object.NewString("7"), 0) != 7 || + sinatraInt(object.NewString("x"), 9) != 9 || sinatraInt(object.NilV, 4) != 4 { + t.Error("sinatraInt") + } + if sinatraVerbName("GET") != "get" { + t.Error("sinatraVerbName") + } + checks := []struct { + in object.Value + want any + }{ + {object.Bool(true), true}, + {object.Integer(5), 5}, + {object.NewString("s"), "s"}, + {object.Symbol("y"), "y"}, + {object.NilV, ""}, + } + for _, c := range checks { + if got := sinatraSettingValue(c.in); got != c.want { + t.Errorf("sinatraSettingValue(%#v)=%#v want %#v", c.in, got, c.want) + } + } +} + +// TestSinatraHaltArgs covers the Integer / String / Array / ignored arms. +func TestSinatraHaltArgs(t *testing.T) { + got := sinatraHaltArgs([]object.Value{ + object.Integer(500), + object.NewString("body"), + &object.Array{Elems: []object.Value{object.NewString("a"), object.Integer(2)}}, + object.NilV, // ignored + }) + if len(got) != 3 { + t.Fatalf("len=%d want 3 (%#v)", len(got), got) + } + if got[0] != 500 || got[1] != "body" { + t.Errorf("scalar arms got=%#v", got) + } + parts, ok := got[2].([]string) + if !ok || len(parts) != 2 || parts[0] != "a" || parts[1] != "2" { + t.Errorf("array arm got=%#v", got[2]) + } +} + +// TestSinatraStr covers the String / Symbol / default arms. +func TestSinatraStr(t *testing.T) { + if sinatraStr(object.NewString("x")) != "x" || sinatraStr(object.Symbol("y")) != "y" || sinatraStr(object.Integer(3)) != "3" { + t.Error("sinatraStr arms") + } +} + +// TestSinatraChainEmpty covers sinatraChain for a class with no recorded def +// (the defs-map lookup miss) — buildSinatraApp still yields a usable 404 app. +func TestSinatraChainEmpty(t *testing.T) { + src := `require "sinatra/base" +class Empty < Sinatra::Base +end +s,_,_ = Empty.new.call("REQUEST_METHOD"=>"GET","PATH_INFO"=>"/x") +puts s` + if got := eval(t, src); got != "404\n" { + t.Errorf("empty app got=%q", got) + } +} + +// TestSinatraParamsHashDirect exercises sinatraParamsHash against a library +// context built directly, covering the []string (splat) value path. +func TestSinatraParamsHashDirect(t *testing.T) { + app := sinatra.New() + var captured *object.Hash + app.Get("/a/*", func(c *sinatra.Context) any { + captured = sinatraParamsHash(c) + return "ok" + }) + app.CallTuple(rack.Env{"REQUEST_METHOD": "GET", "PATH_INFO": "/a/x", "QUERY_STRING": "n=1"}) + if captured == nil { + t.Fatal("action not invoked") + } + if v, ok := captured.Get(object.NewString("n")); !ok || v.(*object.String).Str() != "1" { + t.Errorf("scalar param missing: %#v", v) + } + if v, ok := captured.Get(object.NewString("splat")); !ok { + t.Errorf("splat param missing") + } else if _, isArr := v.(*object.Array); !isArr { + t.Errorf("splat should be an Array, got %#v", v) + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 680caaea..cc22c6ad 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -220,6 +220,10 @@ type VM struct { cREXMLPretty *RClass // REXML::Formatters::Pretty serialiser cREXMLXPath *RClass // REXML::XPath module cREXMLParseException *RClass // REXML::ParseException < StandardError + cSinatraBase *RClass // Sinatra::Base (require "sinatra/base"), backed by go-ruby-sinatra + cSinatraCtx *RClass // Sinatra::Base::Context, the self a route/filter block runs against + cSinatraSettings *RClass // Sinatra::Base::Settings, the handler's `settings` view + sinatraDefs map[*RClass]*sinatraDef // per-Sinatra::Base-subclass route/filter/handler declarations cOpenSSLDigest *RClass cArray, cHash, cRange *RClass cProc *RClass