From 052c40fd05b76bc39ad44bdc4bd4a30f6860e600 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 20:16:47 +0200 Subject: [PATCH] fix(sinatra): render ERB views in erubi mode (newline fidelity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sinatra 4.x maps :erb to Tilt::ErubiTemplate, so real Sinatra renders views through the erubi gem's Erubi::Engine, not classic MRI ERB. rbgo's Sinatra `erb` helper compiled through go-ruby-erb's default (classic ERB) mode, so a standalone `<%= yield %>` line dropped its trailing newline (classic trim "<>") where erubi keeps it — the fidelity gap flagged in the Sinatra file-views work (#166). Select go-ruby-erb's new erb.ModeErubi (byte-identical to Erubi::Engine) for the Sinatra view render only (internal/vm/sinatra_erb.go). The standalone `require "erb"` / classic-ERB binding (internal/vm/erb.go) stays in the default ModeERB, unchanged. Prove it through the rbgo binary: the Sinatra+ERB webapp oracle gains a layout whose `<%= yield %>` stands alone on its own line (previously kept inline to dodge the classic-ERB gap). The golden was regenerated from the real sinatra gem 4.2.1 + erubi 1.13.1; rbgo now reproduces it byte-for-byte. Reverting to classic "<>" mode fails that request, confirming the fix is load-bearing. Classic ERB conformance is unchanged; sinatra_erb.go holds 100% coverage. - go-ruby-erb: v0.0.0-20260629074717 -> v0.0.0-20260706152133 (ModeErubi) Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 +- go.sum | 4 +- internal/vm/sinatra_erb.go | 30 +++++++++------ .../webapp/apps/sinatra_erb_oracle.rb | 38 ++++++++++++------- .../webapp/sinatra_erb_oracle_test.go | 25 +++++++++--- 5 files changed, 65 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index c1233f6e..bbcba709 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/go-ruby-dry-struct/dry-struct v0.0.0-20260702152517-9e25a927a351 github.com/go-ruby-dry-types/dry-types v0.0.0-20260702150052-94ab5100a720 github.com/go-ruby-dry-validation/dry-validation v0.0.0-20260702151810-a274955bddac - github.com/go-ruby-erb/erb v0.0.0-20260629074717-0999ae4dd529 + github.com/go-ruby-erb/erb v0.0.0-20260706152133-22f94a380d76 github.com/go-ruby-erubi/erubi v0.0.0-20260706150033-cfeeadde9120 github.com/go-ruby-etcd/etcd v0.0.0-20260704112801-4417ab26c89a github.com/go-ruby-excon/excon v0.0.0-20260706115718-5ca851d0373f diff --git a/go.sum b/go.sum index cc9a685e..cc8e6240 100644 --- a/go.sum +++ b/go.sum @@ -240,8 +240,8 @@ github.com/go-ruby-dry-types/dry-types v0.0.0-20260702150052-94ab5100a720 h1:Lc4 github.com/go-ruby-dry-types/dry-types v0.0.0-20260702150052-94ab5100a720/go.mod h1:S+2r7EA5zp4E8JyaJ88koD8gT7k/JD9i8NGj6V6jIRM= github.com/go-ruby-dry-validation/dry-validation v0.0.0-20260702151810-a274955bddac h1:ry6nkQY0B7TQfJD34iTIEqVPgThNkcXFreD116X/o74= github.com/go-ruby-dry-validation/dry-validation v0.0.0-20260702151810-a274955bddac/go.mod h1:LHalD7pLKBYjablQtp55tlPahU5LHCXf0v0mSzmS6zE= -github.com/go-ruby-erb/erb v0.0.0-20260629074717-0999ae4dd529 h1:md/l6ifEY6S0Iy1nBk5hVt5BhnWJRqhB08gVEsVvS28= -github.com/go-ruby-erb/erb v0.0.0-20260629074717-0999ae4dd529/go.mod h1:HEJVI7jQ/BnjiSiu2Wj934QboEAgdv47RNOqvZb+nRw= +github.com/go-ruby-erb/erb v0.0.0-20260706152133-22f94a380d76 h1:CenGI7B67vUppMG1+YV78+IgbmKArmokJFStknRlBxU= +github.com/go-ruby-erb/erb v0.0.0-20260706152133-22f94a380d76/go.mod h1:HEJVI7jQ/BnjiSiu2Wj934QboEAgdv47RNOqvZb+nRw= github.com/go-ruby-erubi/erubi v0.0.0-20260706150033-cfeeadde9120 h1:+CId0Dae0fU9zVcyryFoY8l/cqTpFSEvgv9RBXiZDDU= github.com/go-ruby-erubi/erubi v0.0.0-20260706150033-cfeeadde9120/go.mod h1:jZJcpxqgDFkKAW7K6Z/WwrWdF7BQ78dYUopimi986ls= github.com/go-ruby-etcd/etcd v0.0.0-20260704112801-4417ab26c89a h1:aFqb/Uizc/H0UPz6uv5qWpyFVopuPlPjgXevBlV7HMo= diff --git a/internal/vm/sinatra_erb.go b/internal/vm/sinatra_erb.go index 614c9b7f..933fa3c0 100644 --- a/internal/vm/sinatra_erb.go +++ b/internal/vm/sinatra_erb.go @@ -19,11 +19,18 @@ import ( // _erbout buffer and returning the rendered String. That String becomes the Rack // response body via sinatraResult, exactly as under MRI Sinatra + Tilt. // -// Trim mode and the output-buffer name match Tilt::ERBTemplate's defaults, which -// Sinatra uses unchanged: trim "<>" (a code-only line's surrounding newlines are -// removed) and eoutvar "_erbout". <%= %> interpolates unescaped (Sinatra does not -// auto-escape unless :erb => { escape_html: true } is configured — a scoped -// follow-up), <% %> is control flow. +// The dialect and the output-buffer name match what Sinatra 4.x actually renders +// with: since Sinatra 2.0 Tilt maps :erb to Tilt::ErubiTemplate, so views compile +// through the erubi gem's Erubi::Engine, not classic MRI ERB. go-ruby-erb's +// ModeErubi reproduces Erubi::Engine byte-for-byte (default engine options: trim +// on, escape off), which is why the Sinatra render selects erb.ModeErubi here +// (erubi has no trim_mode, so TrimMode is not passed) while the standalone +// `require "erb"` / classic-ERB binding in erb.go stays in the default ModeERB. +// The output buffer is eoutvar "_erbout" (Tilt's default). <%= %> interpolates +// unescaped (Sinatra does not auto-escape unless :erb => { escape_html: true } is +// configured — a scoped follow-up), <% %> is control flow. Unlike classic ERB +// trim "<>", erubi keeps the trailing newline of a standalone `<%= … %>` line +// (e.g. a `<%= yield %>` alone on its line in a layout), matching MRI Sinatra. // // Two view render paths land: an inline-String template (needs no filesystem) // and a :symbol template naming views/.erb (read through the File binding, @@ -39,13 +46,8 @@ import ( // (`erb :part` composed inside a view via a helper) and the :erb escape_html / // app-level `set :erb, layout:` engine options remain a scoped follow-up. -// sinatraTrimMode is the ERB trim mode Sinatra renders with: Tilt::ERBTemplate -// maps a nil/true :trim option (Sinatra's default) to "<>", so a line holding -// only a code tag has both its surrounding newlines trimmed. -const sinatraTrimMode = "<>" - // sinatraEOutVar names the output buffer the compiled template appends to, matching -// Tilt::ERBTemplate's default output variable. +// Tilt::ErubiTemplate's default output variable. const sinatraEOutVar = "_erbout" // sinatraErb implements the `erb` view helper on a request context. template is @@ -70,7 +72,11 @@ func (vm *VM) sinatraErb(sc *SinatraCtx, args []object.Value) object.Value { // non-nil, the yield block (used to expose the wrapped view to a layout's // `<%= yield %>`). It returns the rendered String. func (vm *VM) sinatraErbRender(sc *SinatraCtx, tmpl string, names []string, vals []object.Value, block *Proc) object.Value { - src, _, err := erbCompile(tmpl, erb.Options{TrimMode: sinatraTrimMode, EOutVar: sinatraEOutVar}) + // ModeErubi: Sinatra 4.x renders ERB views through erubi (Tilt::ErubiTemplate), + // so go-ruby-erb compiles in erubi mode to stay byte-identical — notably a + // standalone `<%= yield %>` line keeps its trailing newline (classic ERB trim + // "<>" would drop it). The classic `require "erb"` binding stays in ModeERB. + src, _, err := erbCompile(tmpl, erb.Options{Mode: erb.ModeErubi, EOutVar: sinatraEOutVar}) if err != nil { // go-ruby-erb never fails on a well-formed template (its err is reserved for // genuinely malformed options, unreachable through this fixed-option call); diff --git a/scripts/conformance/webapp/apps/sinatra_erb_oracle.rb b/scripts/conformance/webapp/apps/sinatra_erb_oracle.rb index 771f2a80..754390c5 100644 --- a/scripts/conformance/webapp/apps/sinatra_erb_oracle.rb +++ b/scripts/conformance/webapp/apps/sinatra_erb_oracle.rb @@ -19,16 +19,13 @@ # ruby 4.0.5). To regenerate the golden vector, run this file with `ruby` and the # sinatra gem installed. # -# Trim note (a precisely-scoped go-ruby-erb LIBRARY follow-up, NOT a binding gap): -# Sinatra 4.x maps :erb to Tilt::ErubiTemplate (erubi), whereas go-ruby-erb -# mirrors MRI's ERB::Compiler. Rendered with trim "<>" (Tilt::ERBTemplate's own -# default) the two agree byte-for-byte for single-line templates and idiomatic -# multiline templates (a code-only `<% … %>` alone on its line). They diverge only -# for the unusual line that BOTH opens a code block and ends with an expression -# tag with trailing content (`<% each %>x <%= v %>`), where MRI ERB trims the -# trailing newline and erubi keeps it. This fixture stays inside the agreeing -# subset, so the diff is exact; full erubi trim-parity is tracked upstream in -# go-ruby-erb. +# Dialect note: Sinatra 4.x maps :erb to Tilt::ErubiTemplate (erubi), and rbgo now +# renders Sinatra views through go-ruby-erb's ModeErubi, which reproduces +# Erubi::Engine byte-for-byte. So this fixture exercises erubi's own whitespace +# semantics directly — including a standalone `<%= yield %>` line (route +# /l_standalone) whose trailing newline erubi keeps but classic ERB trim "<>" would +# drop. That case was previously kept inline to dodge the classic-ERB divergence; +# it is now asserted end-to-end, matching the real gem. require "sinatra/base" require "tmpdir" @@ -93,13 +90,20 @@ class App < Sinatra::Base ) # The default layout: surrounding markup with `<%= yield %>` interpolating the # wrapped view and an @ivar the before-filter set (the layout renders in the same -# handler binding as the view). `yield` is kept inline (not alone on a line) so the -# fixture stays inside the ERB/erubi trim-agreement subset the oracle relies on — -# a standalone `<%= %>` line is the one documented go-ruby-erb LIBRARY divergence. +# handler binding as the view). Here `yield` is kept inline within
…
. File.write( File.join(LVIEWS, "layout.erb"), "\nSite\n
<%= yield %>
\n
by <%= @who %>
\n" ) +# A layout with `<%= yield %>` STANDALONE on its own line — the erubi-specific case +# the classic-ERB oracle used to avoid. Under erubi the yield line keeps its +# trailing newline, so a blank-looking break appears between the wrapped view and +# the footer; classic ERB trim "<>" would have swallowed that newline. Asserting it +# byte-for-byte proves rbgo renders Sinatra views in erubi mode. +File.write( + File.join(LVIEWS, "standalone.erb"), + "
H
\n<%= yield %>\n
F
\n" +) # A custom layout selected with `layout: :alt`, using a render local (proving the # locals reach the layout, not just the view) around `<%= yield %>`. File.write( @@ -140,6 +144,13 @@ class LApp < Sinatra::Base erb :card, { layout: "<%= yield %>\n" }, greeting: "In", items: %w[z] end + # A layout whose `<%= yield %>` stands ALONE on its own line: erubi keeps that + # line's trailing newline (classic ERB trim "<>" would drop it), so a bare + # newline separates the wrapped view from the footer. The newline-fidelity proof. + get "/l_standalone" do + erb :card, { layout: :standalone }, greeting: "So", items: %w[m] + end + # A missing NAMED layout raises Errno::ENOENT (MRI eats the error only for the # implicit default layout, not an explicitly requested one). get "/l_missing_layout" do @@ -176,6 +187,7 @@ class LApp < Sinatra::Base [LApp, { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/l_inline", "QUERY_STRING" => "", "rack.url_scheme" => "http", "HTTP_HOST" => "ex" }], [LApp, { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/l_missing_layout", "QUERY_STRING" => "", "rack.url_scheme" => "http", "HTTP_HOST" => "ex" }], [LApp, { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/l_missing_view", "QUERY_STRING" => "", "rack.url_scheme" => "http", "HTTP_HOST" => "ex" }], + [LApp, { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/l_standalone", "QUERY_STRING" => "", "rack.url_scheme" => "http", "HTTP_HOST" => "ex" }], ] REQS.each_with_index do |(app, env), i| diff --git a/scripts/conformance/webapp/sinatra_erb_oracle_test.go b/scripts/conformance/webapp/sinatra_erb_oracle_test.go index 0643dbda..b7ca1d1b 100644 --- a/scripts/conformance/webapp/sinatra_erb_oracle_test.go +++ b/scripts/conformance/webapp/sinatra_erb_oracle_test.go @@ -4,9 +4,11 @@ import "testing" // sinatraErbGem421Golden is the response dump of apps/sinatra_erb_oracle.rb // captured from the REAL `sinatra` gem 4.2.1 (MRI ruby 4.0.5) with its ERB view -// engine: thirteen requests through the classic Sinatra::Base #call(env) adapter, -// each rendering an ERB view (six inline/symbol templates, then seven exercising -// the layout rule) and dumping [status, body]. This is the golden oracle for the +// engine — which is erubi (Tilt::ErubiTemplate), the erubi gem 1.13.1: fourteen +// requests through the classic Sinatra::Base #call(env) adapter, each rendering an +// ERB view (six inline/symbol templates, then eight exercising the layout rule, +// the last a standalone `<%= yield %>` line whose trailing newline erubi keeps but +// classic ERB trim "<>" drops) and dumping [status, body]. This is the golden oracle for the // templating half of the Sinatra binding: rbgo, rendering through the bound // go-ruby-erb compiler, must reproduce it byte-for-byte. It is a captured constant // so the suite stays hermetic (no ruby, no gem, runs under qemu on every 64-bit @@ -90,6 +92,16 @@ body<> == req 12 == status=200 body<> +== req 13 == +status=200 +body<<
H
+

So World

+
    +
  • m
  • +
+ +
F
+>> ` // TestSinatraErbGemOracle proves the Sinatra `erb` view helper is MRI-identical @@ -101,9 +113,10 @@ body<> // control flow, positional and options[:locals] locals, ERB trim behaviour, and // the layout rule (default views/layout.erb with `<%= yield %>` returning the // view, layout: false, a custom layout: :name, layout: true, an inline-String -// layout, and the Errno::ENOENT a missing named layout / view raises) — all -// rendered by rbgo (via the bound go-ruby-erb compiler, evaluated in the -// handler's binding) exactly as by the reference gem. +// layout, a layout with a standalone `<%= yield %>` line — the erubi +// newline-fidelity case — and the Errno::ENOENT a missing named layout / view +// raises) — all rendered by rbgo (via the bound go-ruby-erb compiler in erubi +// mode, evaluated in the handler's binding) exactly as by the reference gem. func TestSinatraErbGemOracle(t *testing.T) { if ok, detail := featureLoadable("sinatra/base"); !ok { t.Fatalf("sinatra/base must be loadable for the ERB oracle: %s", detail)