From cae39eb10cb2e05b17dec2eeda80e3876747c76e Mon Sep 17 00:00:00 2001 From: dchuk <38986+dchuk@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:35:58 -0700 Subject: [PATCH 1/5] Encode primitive inline JSON with the shared JSON runtime Closes #371 Co-Authored-By: Codex --- docs/pipeline/runtime.md | 8 ++- src/lower/as_json_poro.rs | 5 +- src/lower/controller_to_library/rewrites.rs | 56 ++++++++++++++++-- tests/render_json_primitives.rs | 64 +++++++++++++++++++++ 4 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 tests/render_json_primitives.rs diff --git a/docs/pipeline/runtime.md b/docs/pipeline/runtime.md index 316cb921d..ba2cd330f 100644 --- a/docs/pipeline/runtime.md +++ b/docs/pipeline/runtime.md @@ -741,12 +741,18 @@ an already-encoded body, `render plain: v.as_json_str, content_type: target; `JsonBuilder` is shared runtime, so the ruby lane runs the very writer the compiled lane does. Demand-gated and type-gated: only a class the analyzer typed at a `render json:` site is given the pair. A value -with no writer — a Hash literal, a Relation, a class with its own +with no writer — a collection containing objects or temporal values, a Relation, a class with its own `as_json` (whose pairs `as_json_shape` recognizes but whose computed values are not yet typed, see that module) — keeps the runtime encoder, CRuby-only and loud elsewhere; the suite ledger's `render-json-encoder` rule is the tripwire for it. +Inline Hash/Array payloads whose inferred contents are JSON primitives use +the target's existing `JSON.generate` encoder, including nested primitive +collections. Unknown values and values requiring Rails `as_json` hooks do +not take this path. The generic `render_json_primitives` regression runs on +CRuby and compiled Spinel and retains a nested-Time serialization control. + ### Active Storage: rows and bytes are modeled, variants are a seam `runtime/ruby/active_storage.rb` models the attachment ROWS and the diff --git a/src/lower/as_json_poro.rs b/src/lower/as_json_poro.rs index 461ec25ea..b578bf17a 100644 --- a/src/lower/as_json_poro.rs +++ b/src/lower/as_json_poro.rs @@ -66,8 +66,9 @@ //! records (`render json: @stories`, an `Array[Story]` or a //! `Relation[Story]`) is a JSON array of each record's text. //! -//! What keeps the runtime encoder, CRuby-only as before: a Hash -//! literal, a value the analyzer could not type, and a model whose +//! Primitive-only Hash/Array values use JSON.generate in the controller +//! rewrite. What keeps the runtime encoder, CRuby-only as before: a +//! collection containing objects or temporal values, an untyped value, and a model whose //! `as_json` is outside the two idioms or has a value with no encoding //! here (a nested record, a Hash). diff --git a/src/lower/controller_to_library/rewrites.rs b/src/lower/controller_to_library/rewrites.rs index be50c13b8..d747f7b45 100644 --- a/src/lower/controller_to_library/rewrites.rs +++ b/src/lower/controller_to_library/rewrites.rs @@ -10,6 +10,7 @@ use crate::dialect::Action; use crate::expr::{ArrayStyle, Expr, ExprNode, LValue, Literal}; use crate::ident::{Symbol, VarId}; use crate::span::Span; +use crate::ty::Ty; use super::params::{ParamsSpec, ParamsSpecs}; use super::util::map_expr; @@ -370,8 +371,9 @@ pub(super) fn rewrite_render_to_views( "plain" => body = Some((v.clone(), Some("text/plain"))), // Inline `render json: ` — the body is // the JSON encoding of the value. Encoding - // happens at runtime (`JsonRender.encode` - // walks as_json/Hash/Array/Time), because the + // happens at runtime (JSON.generate for typed + // primitive collections; JsonRender.encode for + // values needing as_json/Time handling), because the // value's shape is a runtime fact — for what // reaches here. A value typed as a class with // declared readers never does: `as_json_poro` @@ -838,8 +840,8 @@ fn strip_format_kwarg(arg: &Expr) -> Option { /// just this entry. The runtime's `render(body, status:, content_type:)` /// expects ONE kwargs hash, not multiple. /// `ActionController::JsonRender.encode()` — the runtime JSON -/// encoder behind inline `render json: ` whose value -/// `as_json_poro` could not write a serializer for. CRuby answers it via +/// encoder behind inline `render json: ` whose value neither +/// `as_json_poro` nor the primitive-collection path handles. CRuby answers it via /// the overlay (as_json-aware recursive encode); a strict target whose /// app reaches this call surfaces an unresolved-constant gap loudly /// rather than silently rendering html. @@ -891,17 +893,29 @@ fn html_escape_call(value: &Expr) -> Expr { } fn json_render_encode(value: &Expr) -> Expr { + // JSON's bundled encoder already handles primitive collections on every + // target. Keep values that need Rails' as_json hooks (including nested + // models and Time) on the existing serializer path. + let collection = matches!(&*value.node, ExprNode::Hash { .. } | ExprNode::Array { .. }) + || value.ty.as_ref().is_some_and(|ty| { + matches!(ty, Ty::Hash { .. } | Ty::Array { .. } | Ty::Record { .. } | Ty::Tuple { .. }) + }); + let primitive_collection = collection && json_primitive_value(value); let recv = Expr::new( value.span, ExprNode::Const { - path: vec![Symbol::from("ActionController"), Symbol::from("JsonRender")], + path: if primitive_collection { + vec![Symbol::from("JSON")] + } else { + vec![Symbol::from("ActionController"), Symbol::from("JsonRender")] + }, }, ); Expr::new( value.span, ExprNode::Send { recv: Some(recv), - method: Symbol::from("encode"), + method: Symbol::from(if primitive_collection { "generate" } else { "encode" }), args: vec![value.clone()], block: None, parenthesized: true, @@ -909,6 +923,36 @@ fn json_render_encode(value: &Expr) -> Expr { ) } +fn json_primitive_value(value: &Expr) -> bool { + if value.ty.as_ref().is_some_and(json_primitive_type) { + return true; + } + // Empty literals have unconstrained element types. Their source shape + // still proves they contain no value requiring an as_json hook, also + // when nested inside another literal collection. + match &*value.node { + ExprNode::Array { elements, .. } => elements.iter().all(json_primitive_value), + ExprNode::Hash { entries, .. } => entries.iter().all(|(key, value)| { + matches!(key.ty.as_ref(), Some(Ty::Str | Ty::Sym)) && json_primitive_value(value) + }), + _ => false, + } +} + +fn json_primitive_type(ty: &Ty) -> bool { + match ty { + Ty::Str | Ty::Sym | Ty::Int | Ty::Float | Ty::Bool | Ty::Nil | Ty::Bottom => true, + Ty::Array { elem } => json_primitive_type(elem), + Ty::Hash { key, value } => { + matches!(key.as_ref(), Ty::Str | Ty::Sym | Ty::Bottom) + && json_primitive_type(value) + } + Ty::Record { row } => row.rest.is_none() && row.fields.values().all(json_primitive_type), + Ty::Tuple { elems } | Ty::Union { variants: elems } => elems.iter().all(json_primitive_type), + _ => false, + } +} + /// Add `key: value` to the trailing kwargs hash — unless the call site /// ALREADY passes that key, in which case the author's value stands. /// diff --git a/tests/render_json_primitives.rs b/tests/render_json_primitives.rs new file mode 100644 index 000000000..bb8ad036b --- /dev/null +++ b/tests/render_json_primitives.rs @@ -0,0 +1,64 @@ +//! Inline primitive JSON must use an encoder present in the compiled tree. +#[path = "support/emit_and_run.rs"] +mod emit_and_run; + +fn app() -> emit_and_run::Overlay { + emit_and_run::empty_app() + .write("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n") + .write("db/schema.rb", "ActiveRecord::Schema.define do\n create_table \"widgets\", force: :cascade do |t|\n t.string \"name\"\n end\nend\n") + .write("config/routes.rb", "Rails.application.routes.draw do\n get \"/payload\", to: \"payloads#show\"\n get \"/list\", to: \"payloads#index\"\nend\n") + .write("app/controllers/payloads_controller.rb", r#"class PayloadsController < ApplicationController + def show + render json: { message: "hello\n\"world\"", count: 2, active: true, missing: nil, nested: { tags: ["one", "two"], empty: [], object: {} } }, status: 202 + end + def index + render json: [{ name: "first", count: 1 }, { name: "second", count: 2 }] + end +end +"#) +} + +const ASSERTIONS: &str = r#" +require_relative "app/controllers/payloads_controller" +controller = PayloadsController.new +controller.process_action(:show) +raise "wrong status" unless controller.status == 202 +raise "wrong content type" unless controller.content_type == "application/json" +raise controller.body unless controller.body == '{"message":"hello\n\"world\"","count":2,"active":true,"missing":null,"nested":{"tags":["one","two"],"empty":[],"object":{}}}' +controller = PayloadsController.new +controller.process_action(:index) +raise controller.body unless controller.body == '[{"name":"first","count":1},{"name":"second","count":2}]' +puts "primitive JSON passed" +"#; + +#[test] +fn inline_primitive_json_runs() { + app().run_ruby(ASSERTIONS).assert_passes(); +} + +#[test] +fn a_nested_time_keeps_rails_json_serialization() { + app() + .write("app/controllers/payloads_controller.rb", r#"class PayloadsController < ApplicationController + def show + render json: { at: Time.utc(2026, 7, 1, 12, 34, 56) } + end + def index + head :no_content + end +end +"#) + .run_ruby(r#" +require_relative "app/controllers/payloads_controller" +controller = PayloadsController.new +controller.process_action(:show) +raise controller.body unless controller.body == '{"at":"2026-07-01T12:34:56.000Z"}' +"#) + .assert_passes(); +} + +#[test] +#[ignore = "requires the Spinel toolchain"] +fn inline_primitive_json_runs_on_spinel() { + app().run_spinel(ASSERTIONS).assert_passes(); +} From e8c3e5ec63865c08623ef50406713c7fe53e7b79 Mon Sep 17 00:00:00 2001 From: dchuk <38986+dchuk@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:04:19 -0700 Subject: [PATCH 2/5] Cover conditional primitive collections in inline JSON rendering Exercise both Hash and Array branches on CRuby and native Spinel. Co-Authored-By: Codex --- src/lower/controller_to_library/rewrites.rs | 14 +++++++++++--- tests/render_json_primitives.rs | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/lower/controller_to_library/rewrites.rs b/src/lower/controller_to_library/rewrites.rs index d747f7b45..f296d70f2 100644 --- a/src/lower/controller_to_library/rewrites.rs +++ b/src/lower/controller_to_library/rewrites.rs @@ -897,9 +897,7 @@ fn json_render_encode(value: &Expr) -> Expr { // target. Keep values that need Rails' as_json hooks (including nested // models and Time) on the existing serializer path. let collection = matches!(&*value.node, ExprNode::Hash { .. } | ExprNode::Array { .. }) - || value.ty.as_ref().is_some_and(|ty| { - matches!(ty, Ty::Hash { .. } | Ty::Array { .. } | Ty::Record { .. } | Ty::Tuple { .. }) - }); + || value.ty.as_ref().is_some_and(json_collection_type); let primitive_collection = collection && json_primitive_value(value); let recv = Expr::new( value.span, @@ -923,6 +921,16 @@ fn json_render_encode(value: &Expr) -> Expr { ) } +fn json_collection_type(ty: &Ty) -> bool { + match ty { + Ty::Hash { .. } | Ty::Array { .. } | Ty::Record { .. } | Ty::Tuple { .. } => true, + Ty::Union { variants } => { + !variants.is_empty() && variants.iter().all(json_collection_type) + } + _ => false, + } +} + fn json_primitive_value(value: &Expr) -> bool { if value.ty.as_ref().is_some_and(json_primitive_type) { return true; diff --git a/tests/render_json_primitives.rs b/tests/render_json_primitives.rs index bb8ad036b..8fac09e5a 100644 --- a/tests/render_json_primitives.rs +++ b/tests/render_json_primitives.rs @@ -6,7 +6,7 @@ fn app() -> emit_and_run::Overlay { emit_and_run::empty_app() .write("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n") .write("db/schema.rb", "ActiveRecord::Schema.define do\n create_table \"widgets\", force: :cascade do |t|\n t.string \"name\"\n end\nend\n") - .write("config/routes.rb", "Rails.application.routes.draw do\n get \"/payload\", to: \"payloads#show\"\n get \"/list\", to: \"payloads#index\"\nend\n") + .write("config/routes.rb", "Rails.application.routes.draw do\n get \"/payload\", to: \"payloads#show\"\n get \"/list\", to: \"payloads#index\"\n get \"/choose\", to: \"payloads#choose\"\nend\n") .write("app/controllers/payloads_controller.rb", r#"class PayloadsController < ApplicationController def show render json: { message: "hello\n\"world\"", count: 2, active: true, missing: nil, nested: { tags: ["one", "two"], empty: [], object: {} } }, status: 202 @@ -14,6 +14,9 @@ fn app() -> emit_and_run::Overlay { def index render json: [{ name: "first", count: 1 }, { name: "second", count: 2 }] end + def choose + render json: (params[:shape] == "array" ? ["one"] : { name: "one" }) + end end "#) } @@ -28,6 +31,14 @@ raise controller.body unless controller.body == '{"message":"hello\n\"world\""," controller = PayloadsController.new controller.process_action(:index) raise controller.body unless controller.body == '[{"name":"first","count":1},{"name":"second","count":2}]' +controller = PayloadsController.new +controller.params = {"shape" => "array"} +controller.process_action(:choose) +raise controller.body unless controller.body == '["one"]' +controller = PayloadsController.new +controller.params = {"shape" => "hash"} +controller.process_action(:choose) +raise controller.body unless controller.body == '{"name":"one"}' puts "primitive JSON passed" "#; @@ -46,6 +57,9 @@ fn a_nested_time_keeps_rails_json_serialization() { def index head :no_content end + def choose + head :no_content + end end "#) .run_ruby(r#" From 9e8208cce3e1aa0accfe97dab7b006d60a0a45dd Mon Sep 17 00:00:00 2001 From: dchuk <38986+dchuk@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:48:47 -0700 Subject: [PATCH 3/5] Document primitive JSON encoder selection Address CodeRabbit documentation coverage feedback. Only comments change; the previously validated runtime and regression logic are unchanged. Co-Authored-By: Codex --- src/lower/controller_to_library/rewrites.rs | 7 +++++++ tests/render_json_primitives.rs | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/lower/controller_to_library/rewrites.rs b/src/lower/controller_to_library/rewrites.rs index f296d70f2..0e0d59f56 100644 --- a/src/lower/controller_to_library/rewrites.rs +++ b/src/lower/controller_to_library/rewrites.rs @@ -114,6 +114,7 @@ pub(super) fn partial_view_call_with_record( )) } +/// Lower controller render calls to view invocations or typed inline responses. pub(super) fn rewrite_render_to_views( expr: &Expr, module_name: Option<&str>, @@ -892,6 +893,8 @@ fn html_escape_call(value: &Expr) -> Expr { ) } +/// Select the bundled JSON encoder for proven primitive collections; retain +/// Rails serialization for values requiring custom hooks or temporal conversion. fn json_render_encode(value: &Expr) -> Expr { // JSON's bundled encoder already handles primitive collections on every // target. Keep values that need Rails' as_json hooks (including nested @@ -921,6 +924,7 @@ fn json_render_encode(value: &Expr) -> Expr { ) } +/// Recognize collection types, including nonempty unions of only collections. fn json_collection_type(ty: &Ty) -> bool { match ty { Ty::Hash { .. } | Ty::Array { .. } | Ty::Record { .. } | Ty::Tuple { .. } => true, @@ -931,6 +935,8 @@ fn json_collection_type(ty: &Ty) -> bool { } } +/// Prove primitive contents from inferred types or nested literal shapes, +/// including empty literals whose element types remain unconstrained. fn json_primitive_value(value: &Expr) -> bool { if value.ty.as_ref().is_some_and(json_primitive_type) { return true; @@ -947,6 +953,7 @@ fn json_primitive_value(value: &Expr) -> bool { } } +/// Accept only scalar JSON values and recursively primitive, closed collections. fn json_primitive_type(ty: &Ty) -> bool { match ty { Ty::Str | Ty::Sym | Ty::Int | Ty::Float | Ty::Bool | Ty::Nil | Ty::Bottom => true, diff --git a/tests/render_json_primitives.rs b/tests/render_json_primitives.rs index 8fac09e5a..4d9875c19 100644 --- a/tests/render_json_primitives.rs +++ b/tests/render_json_primitives.rs @@ -2,6 +2,7 @@ #[path = "support/emit_and_run.rs"] mod emit_and_run; +/// Build generic controllers covering literal and conditional primitive payloads. fn app() -> emit_and_run::Overlay { emit_and_run::empty_app() .write("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n") @@ -42,11 +43,13 @@ raise controller.body unless controller.body == '{"name":"one"}' puts "primitive JSON passed" "#; +/// CRuby preserves primitive payload bytes, status, and content type. #[test] fn inline_primitive_json_runs() { app().run_ruby(ASSERTIONS).assert_passes(); } +/// Temporal values must retain Rails serialization instead of primitive encoding. #[test] fn a_nested_time_keeps_rails_json_serialization() { app() @@ -71,6 +74,7 @@ raise controller.body unless controller.body == '{"at":"2026-07-01T12:34:56.000Z .assert_passes(); } +/// The compiled runtime handles the same primitive and conditional payloads. #[test] #[ignore = "requires the Spinel toolchain"] fn inline_primitive_json_runs_on_spinel() { From 559d5815024eb6e3a8e525d3eda4392b89c29e52 Mon Sep 17 00:00:00 2001 From: dchuk <38986+dchuk@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:02:31 -0700 Subject: [PATCH 4/5] Match Rails HTML escaping for primitive JSON responses Escape HTML entities after primitive JSON generation through a shared typed runtime helper. Preserve serializer fallback, cover exact response bytes on CRuby and native Spinel, and document remaining encoder divergences. Co-Authored-By: Codex --- docs/pipeline/runtime.md | 17 ++++++++++++++--- runtime/ruby/json_builder.rb | 8 ++++++++ runtime/ruby/json_builder.rbs | 1 + runtime/ruby/test/json_builder_test.rb | 11 +++++++++++ src/lower/controller_to_library/rewrites.rs | 20 +++++++++++++++++++- tests/render_json_primitives.rs | 4 ++-- 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/pipeline/runtime.md b/docs/pipeline/runtime.md index ff6c31459..67643ca77 100644 --- a/docs/pipeline/runtime.md +++ b/docs/pipeline/runtime.md @@ -749,9 +749,20 @@ encoder, CRuby-only and loud elsewhere; the suite ledger's Inline Hash/Array payloads whose inferred contents are JSON primitives use the target's existing `JSON.generate` encoder, including nested primitive -collections. Unknown values and values requiring Rails `as_json` hooks do -not take this path. The generic `render_json_primitives` regression runs on -CRuby and compiled Spinel and retains a nested-Time serialization control. +collections. The shared `JsonBuilder.escape_html_entities` helper then +escapes `<`, `>` and `&` to their JSON Unicode forms, matching Rails' default +HTML-entity escaping without re-escaping the encoded document. Unknown values +and values requiring Rails `as_json` hooks do not take this path. The generic +`render_json_primitives` regression runs on CRuby and compiled Spinel, checks +the exact bytes for `&`, and retains a CRuby nested-Time serialization +control. + +Remaining divergences: non-finite Float values (NaN and positive/negative +Infinity) still raise `JSON::GeneratorError` instead of Rails' `null` because +this path delegates primitive encoding to `JSON.generate`. The helper applies +the Rails 8.1+ defaults: HTML-entity escaping enabled, U+2028/U+2029 escaping +disabled. Per-application changes to those Rails encoder settings are not +reflected here. ### Active Storage: rows and bytes are modeled, variants are a seam diff --git a/runtime/ruby/json_builder.rb b/runtime/ruby/json_builder.rb index f7cf023fd..fa2439505 100644 --- a/runtime/ruby/json_builder.rb +++ b/runtime/ruby/json_builder.rb @@ -43,6 +43,7 @@ module JsonBuilder # Ruby/JS/Crystal/RE2 all accept the hex escape, so this is the # cross-target spelling. ESCAPE_PATTERN = /[\\"\n\r\t\x08\f<>&]/.freeze + HTML_ESCAPE_PATTERN = /[<>&]/.freeze # Escape a string for embedding inside JSON double-quotes. Does # NOT add the surrounding quotes — `encode_value` wraps a String @@ -57,6 +58,13 @@ def self.encode_string(s) s.gsub(ESCAPE_PATTERN, ESCAPES) end + # Apply Rails' default HTML-entity escaping to an already encoded JSON + # document. These characters occur only inside JSON strings, so escaping + # the document preserves its structure and existing JSON escapes. + def self.escape_html_entities(json) + json.gsub(HTML_ESCAPE_PATTERN, ESCAPES) + end + # Render a scalar Ruby value as its JSON fragment, complete with # surrounding quotes for strings. Returns a String the lowered # body can concatenate directly into the io accumulator. diff --git a/runtime/ruby/json_builder.rbs b/runtime/ruby/json_builder.rbs index a8e7030c4..537bc2cf7 100644 --- a/runtime/ruby/json_builder.rbs +++ b/runtime/ruby/json_builder.rbs @@ -1,5 +1,6 @@ module JsonBuilder def self.encode_string: (String) -> String + def self.escape_html_entities: (String) -> String def self.encode_value: (untyped) -> String def self.encode_datetime: (String?) -> String def self.encode_string_array: (Array[String]) -> String diff --git a/runtime/ruby/test/json_builder_test.rb b/runtime/ruby/test/json_builder_test.rb index e47c76357..798bdb00e 100644 --- a/runtime/ruby/test/json_builder_test.rb +++ b/runtime/ruby/test/json_builder_test.rb @@ -27,6 +27,17 @@ def test_encode_string_escapes_html_entities assert_equal "\\u003cb\\u003e\\u0026\\u003c/b\\u003e", JsonBuilder.encode_string("&") end + def test_escape_html_entities_preserves_json_structure_and_existing_escapes + json = %q({"":"&","escaped":"\\n\\u003c"}) + expected = %q({"\\u003ctag\\u003e":"\\u003cb\\u003e\\u0026\\u003c/b\\u003e","escaped":"\\n\\u003c"}) + assert_equal expected, JsonBuilder.escape_html_entities(json) + end + + def test_escape_html_entities_preserves_rails_8_1_line_separators + json = "{\"separators\":\"\u2028\u2029\"}" + assert_equal json, JsonBuilder.escape_html_entities(json) + end + # ── encode_value ─────────────────────────────────────────────── def test_encode_value_nil diff --git a/src/lower/controller_to_library/rewrites.rs b/src/lower/controller_to_library/rewrites.rs index 0e0d59f56..14cde764b 100644 --- a/src/lower/controller_to_library/rewrites.rs +++ b/src/lower/controller_to_library/rewrites.rs @@ -912,7 +912,7 @@ fn json_render_encode(value: &Expr) -> Expr { }, }, ); - Expr::new( + let encoded = Expr::new( value.span, ExprNode::Send { recv: Some(recv), @@ -921,6 +921,24 @@ fn json_render_encode(value: &Expr) -> Expr { block: None, parenthesized: true, }, + ); + if !primitive_collection { + return encoded; + } + // Rails escapes HTML entities after JSON generation. Keep that behavior + // in shared typed runtime code, without re-escaping the JSON syntax. + Expr::new( + value.span, + ExprNode::Send { + recv: Some(Expr::new( + value.span, + ExprNode::Const { path: vec![Symbol::from("JsonBuilder")] }, + )), + method: Symbol::from("escape_html_entities"), + args: vec![encoded], + block: None, + parenthesized: true, + }, ) } diff --git a/tests/render_json_primitives.rs b/tests/render_json_primitives.rs index 4d9875c19..8c385c47a 100644 --- a/tests/render_json_primitives.rs +++ b/tests/render_json_primitives.rs @@ -10,7 +10,7 @@ fn app() -> emit_and_run::Overlay { .write("config/routes.rb", "Rails.application.routes.draw do\n get \"/payload\", to: \"payloads#show\"\n get \"/list\", to: \"payloads#index\"\n get \"/choose\", to: \"payloads#choose\"\nend\n") .write("app/controllers/payloads_controller.rb", r#"class PayloadsController < ApplicationController def show - render json: { message: "hello\n\"world\"", count: 2, active: true, missing: nil, nested: { tags: ["one", "two"], empty: [], object: {} } }, status: 202 + render json: { message: "hello\n\"world\"", html: "&", count: 2, active: true, missing: nil, nested: { tags: ["one", "two"], empty: [], object: {} } }, status: 202 end def index render json: [{ name: "first", count: 1 }, { name: "second", count: 2 }] @@ -28,7 +28,7 @@ controller = PayloadsController.new controller.process_action(:show) raise "wrong status" unless controller.status == 202 raise "wrong content type" unless controller.content_type == "application/json" -raise controller.body unless controller.body == '{"message":"hello\n\"world\"","count":2,"active":true,"missing":null,"nested":{"tags":["one","two"],"empty":[],"object":{}}}' +raise controller.body unless controller.body == '{"message":"hello\n\"world\"","html":"\u003cb\u003e\u0026\u003c/b\u003e","count":2,"active":true,"missing":null,"nested":{"tags":["one","two"],"empty":[],"object":{}}}' controller = PayloadsController.new controller.process_action(:index) raise controller.body unless controller.body == '[{"name":"first","count":1},{"name":"second","count":2}]' From 5dddfeff2780ac39a686c0ff73bae937939558b9 Mon Sep 17 00:00:00 2001 From: dchuk <38986+dchuk@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:17:11 -0700 Subject: [PATCH 5/5] Avoid collisions between JSON and view escaping constants Give the JSON pattern a distinct name and pin coexisting runtime declarations across Go, C#, Crystal, Kotlin, and Swift. Document the HTML response helper controls. Co-Authored-By: Codex --- runtime/ruby/json_builder.rb | 5 +-- runtime/ruby/test/json_builder_test.rb | 2 ++ tests/render_json_primitives.rs | 46 ++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/runtime/ruby/json_builder.rb b/runtime/ruby/json_builder.rb index fa2439505..c95ff5c85 100644 --- a/runtime/ruby/json_builder.rb +++ b/runtime/ruby/json_builder.rb @@ -43,7 +43,8 @@ module JsonBuilder # Ruby/JS/Crystal/RE2 all accept the hex escape, so this is the # cross-target spelling. ESCAPE_PATTERN = /[\\"\n\r\t\x08\f<>&]/.freeze - HTML_ESCAPE_PATTERN = /[<>&]/.freeze + # Some targets flatten runtime constants; keep this distinct from ViewHelpers. + JSON_HTML_ESCAPE_PATTERN = /[<>&]/.freeze # Escape a string for embedding inside JSON double-quotes. Does # NOT add the surrounding quotes — `encode_value` wraps a String @@ -62,7 +63,7 @@ def self.encode_string(s) # document. These characters occur only inside JSON strings, so escaping # the document preserves its structure and existing JSON escapes. def self.escape_html_entities(json) - json.gsub(HTML_ESCAPE_PATTERN, ESCAPES) + json.gsub(JSON_HTML_ESCAPE_PATTERN, ESCAPES) end # Render a scalar Ruby value as its JSON fragment, complete with diff --git a/runtime/ruby/test/json_builder_test.rb b/runtime/ruby/test/json_builder_test.rb index 798bdb00e..efb00d06a 100644 --- a/runtime/ruby/test/json_builder_test.rb +++ b/runtime/ruby/test/json_builder_test.rb @@ -27,12 +27,14 @@ def test_encode_string_escapes_html_entities assert_equal "\\u003cb\\u003e\\u0026\\u003c/b\\u003e", JsonBuilder.encode_string("&") end + # Escaping an encoded document must preserve its syntax and existing escapes. def test_escape_html_entities_preserves_json_structure_and_existing_escapes json = %q({"":"&","escaped":"\\n\\u003c"}) expected = %q({"\\u003ctag\\u003e":"\\u003cb\\u003e\\u0026\\u003c/b\\u003e","escaped":"\\n\\u003c"}) assert_equal expected, JsonBuilder.escape_html_entities(json) end + # Rails 8.1 defaults leave Unicode line and paragraph separators unescaped. def test_escape_html_entities_preserves_rails_8_1_line_separators json = "{\"separators\":\"\u2028\u2029\"}" assert_equal json, JsonBuilder.escape_html_entities(json) diff --git a/tests/render_json_primitives.rs b/tests/render_json_primitives.rs index 8c385c47a..f5e697fad 100644 --- a/tests/render_json_primitives.rs +++ b/tests/render_json_primitives.rs @@ -80,3 +80,49 @@ raise controller.body unless controller.body == '{"at":"2026-07-01T12:34:56.000Z fn inline_primitive_json_runs_on_spinel() { app().run_spinel(ASSERTIONS).assert_passes(); } + +/// These targets flatten runtime constants into one namespace. JSON escaping +/// must coexist with ViewHelpers' HTML escaping when both runtimes are emitted. +#[test] +fn json_and_view_html_escape_constants_do_not_collide() { + use roundhouse::analyze::Analyzer; + use roundhouse::emit::{crystal, csharp, go, kotlin, swift}; + use std::collections::BTreeSet; + use std::path::Path; + + let mut app = roundhouse::ingest::ingest_app(Path::new("fixtures/tiny-blog")) + .expect("ingest tiny-blog"); + Analyzer::new(&app).analyze(&mut app); + let mut collisions = Vec::new(); + for (target, files, json_path, view_path, declaration) in [ + ("Go", go::emit(&app), "app/v2/json_builder.go", "app/v2/view_helpers.go", + "var "), + ("C#", csharp::emit(&app), "app/runtime/JsonBuilder.cs", "app/runtime/ViewHelpers.cs", + "public static partial class RuntimeConstants { public static readonly "), + ("Crystal", crystal::emit(&app), "src/json_builder.cr", "src/view_helpers.cr", + ""), + ("Kotlin", kotlin::emit(&app), "src/main/kotlin/JsonBuilder.kt", "src/main/kotlin/ViewHelpers.kt", + "val "), + ("Swift", swift::emit(&app), "Sources/App/JsonBuilder.swift", "Sources/App/ViewHelpers.swift", + "let "), + ] { + let names = |path: &str| -> BTreeSet { + let file = files.iter().find(|file| file.path == Path::new(path)) + .unwrap_or_else(|| panic!("missing {target} runtime {path}")); + let names: BTreeSet<_> = file.content.lines() + .filter_map(|line| line.strip_prefix(declaration)) + .filter_map(|line| line.split_once(" =")) + .filter_map(|(left, _)| left.split_whitespace().last()) + .filter(|name| name.bytes().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_')) + .map(str::to_string).collect(); + assert!(!names.is_empty(), "no {target} runtime constants found in {path}"); + names + }; + let json_names = names(json_path); + let view_names = names(view_path); + for name in json_names.intersection(&view_names) { + collisions.push(format!("{target}: {name} is declared in both {json_path} and {view_path}")); + } + } + assert!(collisions.is_empty(), "{}", collisions.join("\n")); +}