From 6be75d0941588cbd7bb03f83b58ca4411fd1ada8 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 17 Jul 2026 10:45:23 +0300 Subject: [PATCH 1/2] Rails mode --- .github/workflows/test.yml | 39 +++++++++ CHANGELOG.md | 20 +++++ Cargo.lock | 4 +- Gemfile | 8 +- Gemfile.lock | 68 +++++++++++++- README.md | 59 +++++++++++-- Rakefile | 5 ++ benchmark/rails_benchmark.rb | 125 ++++++++++++++++++++++++++ ext/nosj/Cargo.toml | 2 +- ext/nosj/src/gen/mod.rs | 49 +++++++++-- ext/nosj/src/gen/opts.rs | 78 +++++++++++++++++ ext/nosj/src/gen/ruby.rs | 51 +++++++++++ ext/nosj/src/gen/walker.rs | 156 ++++++++++++++++++++------------- ext/nosj/src/lib.rs | 4 + lib/nosj/json.rb | 32 +++++-- lib/nosj/rails.rb | 73 +++++++++++++++ nosj.gemspec | 5 +- sig/nosj.rbs | 10 +++ spec/generate_spec.rb | 8 ++ spec/json_dropin_spec.rb | 16 ++++ spec/rails_integration_spec.rb | 125 ++++++++++++++++++++++++++ spec/rails_spec.rb | 153 ++++++++++++++++++++++++++++++++ 22 files changed, 1002 insertions(+), 88 deletions(-) create mode 100644 benchmark/rails_benchmark.rb create mode 100644 lib/nosj/rails.rb create mode 100644 spec/rails_integration_spec.rb create mode 100644 spec/rails_spec.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b34582c..15768a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,3 +39,42 @@ jobs: - name: Run specs run: bundle exec rake spec + + # The Rails-facing specs against the oldest supported ActiveSupport + # lines (the main matrix already exercises the newest release): the + # drop-in's quirks_mode path and the encoder seam differ across them. + rails-compat: + name: Rails compat (activesupport ${{ matrix.activesupport }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { activesupport: "~> 7.1.0", ruby: "3.3" } + - { activesupport: "~> 7.2.0", ruby: "3.4" } + - { activesupport: "~> 8.0.0", ruby: "3.4" } + - { activesupport: "~> 8.1.0", ruby: "4.0" } + env: + NOSJ_ACTIVESUPPORT_VERSION: ${{ matrix.activesupport }} + + steps: + - uses: actions/checkout@v6 + + - name: Set up Ruby & Rust + uses: oxidize-rb/actions/setup-ruby-and-rust@v1 + with: + ruby-version: ${{ matrix.ruby }} + cargo-cache: true + + # The committed lockfile resolves the newest ActiveSupport; the + # pin needs a fresh resolution. + - name: Bundle with the pinned ActiveSupport + run: | + rm Gemfile.lock + bundle install + + - name: Compile native extension + run: bundle exec rake compile + + - name: Run the Rails-facing specs + run: bundle exec rspec spec/rails_spec.rb spec/rails_integration_spec.rb spec/json_dropin_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a34576b..94ed756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [Unreleased] + +- Rails mode: `require "nosj/rails"` accelerates a Rails application + in both directions. It installs a nosj-backed ActiveSupport JSON + encoder, so `obj.to_json`, `render json:`, and `ActiveSupport::JSON.encode` walk the object tree natively—values recurse through `as_json` exactly + like ActiveSupport's own encoder. It also loads the `nosj/json` drop-in, so + `ActiveSupport::JSON.decode` and JSON request-body parsing take the + fast path (including on Rails 7.x, whose `quirks_mode` option the + drop-in now accepts; the drop-in also accepts valid-UTF-8 BINARY + strings now, which is what Rack delivers request bodies as). The + HTML-safety escaping is fused into the SIMD string-emission kernels, + so escaped output costs the same single pass as unescaped. Measured + against stock ActiveSupport encoding: ×1.7 on small documents up to + ×5.2 on large trees and ×14 on HTML-heavy content + (`rake bench:rails`). In a Rails Gemfile: + `gem "nosj", require: "nosj/rails"`. +- `JSON::Fragment` values now splice their pre-rendered JSON + everywhere the `json` gem does: in default mode, under `strict: + true`, and through the Rails encoder. + ## [0.2.0] - 2026-07-16 - File APIs. `NOSJ.load_file(path, opts)` parses a file directly diff --git a/Cargo.lock b/Cargo.lock index b4f5a37..fbd32ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,9 +189,9 @@ dependencies = [ [[package]] name = "nosj" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6ecf0db23b7e9944ecee15ab3dd01b0bde0ab2a195442f363847739f667ac8d" +checksum = "d25913644b7871b36095cc3da27daf95ebf9508189c5d8ccfb16da3bdb9f802f" dependencies = [ "fast-float2", ] diff --git a/Gemfile b/Gemfile index 2f3614e..aefcd5a 100644 --- a/Gemfile +++ b/Gemfile @@ -17,8 +17,14 @@ end group :test do gem "rspec", "~> 3.0" - # Pure Ruby; the adapter spec exercises NOSJ::MultiJsonAdapter. + gem "multi_json" + + # Pinnable for CI's rails-compat matrix (e.g. "~> 7.1.0"); unpinned, + # the newest release is what the main matrix exercises. + rails_pin = ENV.fetch("NOSJ_ACTIVESUPPORT_VERSION", nil) + gem "activesupport", *[rails_pin].compact + gem "actionpack", *[rails_pin].compact end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 8668765..9009656 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,20 +7,66 @@ PATH GEM remote: https://rubygems.org/ specs: + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actionview (8.1.3) + activesupport (= 8.1.3) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) ast (2.4.3) + base64 (0.3.0) benchmark-ips (2.15.1) benchmark-memory (0.2.0) memory_profiler (~> 1) bigdecimal (4.1.2) + builder (3.3.0) + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + crass (1.0.7) diff-lcs (1.6.2) + drb (2.2.3) + erubi (1.13.1) fast_jsonparser (0.6.0) + i18n (1.15.2) + concurrent-ruby (~> 1.0) json (2.20.0) language_server-protocol (3.17.0.6) lefthook (2.1.10) lint_roller (1.1.0) logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) memory_profiler (1.1.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) multi_json (1.21.1) + nokogiri (1.19.4-arm64-darwin) + racc (~> 1.4) oj (3.17.3) bigdecimal (>= 3.0) ostruct (>= 0.2) @@ -31,6 +77,19 @@ GEM racc prism (1.9.0) racc (1.8.1) + rack (3.2.6) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.4.2) rake-compiler (1.3.1) @@ -76,6 +135,7 @@ GEM rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) + securerandom (0.4.1) standard (1.55.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -89,17 +149,23 @@ GEM lint_roller (~> 1.1) rubocop-performance (~> 1.26.0) tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) yajl-ruby (1.4.3) yard (0.9.45) PLATFORMS arm64-darwin-24 - ruby + arm64-darwin-25 DEPENDENCIES + actionpack + activesupport benchmark-ips benchmark-memory fast_jsonparser diff --git a/README.md b/README.md index 036c1c2..c396662 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ faster than Yajl—[see Benchmarks](#benchmarks). - It has **lazy documents**: `NOSJ.lazy` wraps a document and parses a value only when you touch it—repeated access costs nanoseconds, and everything you never read is never parsed. - It has a **partial parsing mode**: JSON Pointer lookups that pull single values out of big documents in microseconds, skipping everything else. - It has **file APIs**: parse, generate, dig, and lazy-wrap files directly—no throwaway file-sized Ruby String, and the partial modes memory-map the file so unread pages never even leave the disk. +- It accelerates a **Rails** application in both encoding and decoding. - It comes **precompiled** (platform gems built with per-platform optimizations, nothing to compile on install). - Otherwise, same API and option names as gem json. @@ -50,6 +51,8 @@ NOSJ.generate({"a" => [1, true]}) #=> '{"a":[1,true]}' That's it—if you know the `json` gem, you already know `nosj`. +### gem json compatibility + Want the speedup without touching your code? One line reroutes `JSON.parse`, `JSON.generate`, `JSON.pretty_generate`, and `JSON.dump` through nosj: @@ -65,14 +68,33 @@ Gemfile you can do this: gem "nosj", require: "nosj/json" ``` -Options nosj supports take the fast path; anything exotic -(`create_additions`, `object_class`, `JSON::State`, procs, IO -arguments) falls back to the original implementation, so `JSON.load`, -`JSON.parse!`, and `JSON.load_file` keep their exact behavior. -Exceptions re-raise as the JSON classes, so your rescue clauses keep -working. Measured through the patch: parse 1.11×, generate 1.05× over -the original gem. (A MultiJson adapter ships too: -`require "nosj/multi_json"`, then `MultiJson.use NOSJ::MultiJsonAdapter`.) +A MultiJson adapter ships too: + +```ruby +require "nosj/multi_json" +``` + +And then `MultiJson.use NOSJ::MultiJsonAdapter`. + +### Ruby on Rails + +In a Rails app, use this for "Rails mode": + +```ruby +gem "nosj", require: "nosj/rails" +``` + +That installs a nosj-backed ActiveSupport JSON encoder, so `obj.to_json`, `render json:`, and `ActiveSupport::JSON.encode` walk the object tree natively: +values that aren't JSON-native recurse through `as_json` exactly like +ActiveSupport's own encoder, non-finite floats encode as `null`, and +HTML-safety escaping (`escape_html_entities_in_json`) behaves +identically—verified differentially against ActiveSupport's encoder. +It loads the drop-in too, so `ActiveSupport::JSON.decode` and JSON +request-body parsing ride the fast path. + +Measured against ActiveSupport's own encoder: ×1.9 on small documents +up to ×5.2 on large trees and ×14 on HTML-heavy content—see +[Benchmarks → Rails mode](#rails-mode). ## What's in the box @@ -209,6 +231,27 @@ Silicon dev box, Ruby 4.0.6 + YJIT, PGO build, 2026-07-16): too much for an honest multiplier; what it saves is the intermediate file-sized Ruby String. +### Rails mode + +`ActiveSupport::JSON.encode` with the nosj encoder installed, against +stock ActiveSupport (Apple Silicon dev box, Ruby 4.0.6 + YJIT, +activesupport 8.1, medians of 5 interleaved per-process rounds, +outputs verified byte-identical first, 2026-07-17; +`rake bench:rails`): + +| workload | nosj (i/s) | vs ActiveSupport | +|---|---:|---| +| twitter tree (570 KB) | 4.5k | ×5.2 | +| 100-record index (with timestamps) | 33.7k | ×1.7 | +| HTML-heavy user content | 177.7k | ×14.2 | +| Time/Date/BigDecimal hash | 1.1M | ×3.0 | +| small API hash | 4.2M | ×1.9 | +| small hash `to_json` | 4.1M | ×1.9 | + +The HTML-safety escaping that dominates stock encodes of +user-generated content is fused into the SIMD string-emission kernels +here: escaped output costs the same single pass as unescaped. + Reproduce with `rake bench` (the parity-gated comparison, after a PGO retrain—the shipping configuration) or `rake bench:ips` (the multi-gem shoot-out). ## Switching from the json gem diff --git a/Rakefile b/Rakefile index 44c2258..cec1ea3 100644 --- a/Rakefile +++ b/Rakefile @@ -65,6 +65,11 @@ namespace :bench do task :ips, [:file] => :compile do |_t, args| ruby(*["benchmark/benchmark.rb", args[:file], *args.extras].compact) end + + desc "Rails-mode encoder shoot-out (stock/Oj/nosj) on benchmark-ips" + task rails: :compile do + ruby("benchmark/rails_benchmark.rb") + end end def bench_sweep(args) diff --git a/benchmark/rails_benchmark.rb b/benchmark/rails_benchmark.rb new file mode 100644 index 0000000..ef0f451 --- /dev/null +++ b/benchmark/rails_benchmark.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# Rails-mode encoder shoot-out: ActiveSupport::JSON.encode through the +# stock encoder, Oj's Rails mode (Oj.optimize_rails), and nosj's Rails +# mode (nosj/rails), on benchmark-ips. +# +# Usage: +# bundle exec rake bench:rails +# +# Oj.optimize_rails and nosj/rails patch globally and cannot be undone, +# so the contenders cannot share a process. Every workload is ONE +# ips job listing all contenders, held across invocations +# (`x.hold!` measures the first pending report per invocation and +# loads the rest). The parent byte-compares the contenders' +# outputs, then runs the script once per +# contender in report order; the last pass prints the full `compare!` +# table for every workload. As everywhere else, the trusted numbers +# come from a PGO build (`rake bench:rails` compiles first). + +require "json" +require "tmpdir" + +CONTENDERS = %w[stock nosj oj].freeze +CHILD_ENV = "NOSJ_RAILS_CONTENDER" +LINEUP_ENV = "NOSJ_RAILS_LINEUP" +HOLD_DIR_ENV = "NOSJ_RAILS_HOLD_DIR" + +def workloads + require "bigdecimal" + twitter = JSON.parse(File.read(File.expand_path("twitter.json", __dir__))) + small = {"id" => 1, "name" => "ada", "tags" => ["x", "y"], + "score" => 99.5, "active" => true} + rich = {"time" => Time.at(0).utc, "date" => Date.new(2026, 7, 16), + "dec" => BigDecimal("1.5"), "html" => "", + "floats" => [1.5, Float::NAN], "sym" => :ok} + # A typical index endpoint: rows of records with a timestamp each. + records = Array.new(100) do |i| + {"id" => i, "email" => "user#{i}@example.com", "score" => i * 1.5, + "admin" => i.zero?, "created_at" => Time.at(i * 3600).utc} + end + # Escape-heavy: user-generated content full of HTML. + html_heavy = {"comments" => Array.new(50) do |i| + {"id" => i, "body" => "

Comment #{i} says x < y && y > z

"} + end} + { + "encode twitter (570 KB)" => -> { ActiveSupport::JSON.encode(twitter) }, + "encode 100 records+Time" => -> { ActiveSupport::JSON.encode(records) }, + "encode html-heavy" => -> { ActiveSupport::JSON.encode(html_heavy) }, + "encode Time/Date/BigDecimal" => -> { ActiveSupport::JSON.encode(rich) }, + "encode small hash" => -> { ActiveSupport::JSON.encode(small) }, + "small hash to_json" => -> { small.to_json } + } +end + +def load_contender(name) + require "active_support" + require "active_support/json" + case name + when "oj" + require "oj" + Oj.optimize_rails + when "nosj" + require_relative "../lib/nosj/rails" + end +end + +if (contender = ENV[CHILD_ENV]) + begin + load_contender(contender) + rescue LoadError => e + warn "#{contender} unavailable: #{e.class}" + exit + end + + if ARGV[0] == "--correctness" + workloads.each_value { |blk| puts blk.call } + exit + end + + # One held job per workload, every contender in the lineup as a + # report. hold! measures only the first report without held results, + # which is exactly this invocation's contender because the parent + # runs the lineup in report order; the other blocks never execute + # here. The final invocation has every result and compare! prints + # the shoot-out. + require "benchmark/ips" + RubyVM::YJIT.enable + lineup = ENV.fetch(LINEUP_ENV).split(",") + hold_dir = ENV.fetch(HOLD_DIR_ENV) + workloads.each do |label, blk| + Benchmark.ips do |x| + x.config(warmup: 1, time: 3) + lineup.each { |c| x.report("#{c}: #{label}", &blk) } + x.hold! File.join(hold_dir, label.gsub(/\W+/, "_")) + x.compare! if contender == lineup.last + end + end + exit +end + +# Parent: correctness gate, then one pass per contender. +def run_child(contender, *args, env: {}) + cmd = [RbConfig.ruby, "-I", File.expand_path("../lib", __dir__), __FILE__, *args] + IO.popen(env.merge(CHILD_ENV => contender), cmd, err: [:child, :out], &:read).tap do + abort "#{contender} child failed" unless $?.success? + end +end + +puts "== correctness (every contender must produce stock's exact bytes)" +outputs = CONTENDERS.to_h { |c| [c, run_child(c, "--correctness")] } +available = CONTENDERS.select { |c| !outputs[c].include?("unavailable") } +available.each do |c| + next if outputs[c] == outputs["stock"] + abort "#{c} DIVERGES from the stock encoder:\n#{outputs[c]}" +end +skipped = CONTENDERS - available +puts skipped.empty? ? "ok" : "ok (skipped: #{skipped.join(", ")})" + +Dir.mktmpdir("nosj-rails-bench") do |hold_dir| + env = {LINEUP_ENV => available.join(","), HOLD_DIR_ENV => hold_dir} + available.each do |c| + puts "\n== measuring #{c}" + puts run_child(c, env: env) + end +end diff --git a/ext/nosj/Cargo.toml b/ext/nosj/Cargo.toml index 9b3b58f..37f184d 100644 --- a/ext/nosj/Cargo.toml +++ b/ext/nosj/Cargo.toml @@ -25,4 +25,4 @@ memmap2 = "0.9" # First-party SIMD JSON parse/generate library (github.com/yaroslav/nosj). # For coordinated crate+gem work, temporarily flip to # { path = "../../../nosj" } and restore before any commit or release. -nosj = "0.1.1" +nosj = "0.2.0" diff --git a/ext/nosj/src/gen/mod.rs b/ext/nosj/src/gen/mod.rs index 3b72427..2bf1e8c 100644 --- a/ext/nosj/src/gen/mod.rs +++ b/ext/nosj/src/gen/mod.rs @@ -46,12 +46,17 @@ use walker::Gen; struct GenScratch { buf: Vec, keys: GenKeyCache, + /// Keys pre-escaped under HtmlSafe (the Rails encoder): cached + /// bytes bake in the escape mode, so each cacheable mode owns a + /// cache (see walker::mode_cacheable). + html_keys: GenKeyCache, } thread_local! { static GEN_SCRATCH: RefCell = RefCell::new(GenScratch { buf: Vec::new(), keys: GenKeyCache::with_capacity(256), + html_keys: GenKeyCache::with_capacity(64), }); } @@ -86,6 +91,29 @@ pub fn generate_entry(ruby: &Ruby, _rb_self: Value, args: &[Value]) -> Result Result { + let cfg = match (escape_html, escape_js) { + (true, true) => &opts::RAILS_HTML_SAFE_CONFIG, + (true, false) => &opts::RAILS_HTML_ENTITIES_CONFIG, + (false, true) => &opts::RAILS_JS_SEPARATORS_CONFIG, + (false, false) => &opts::RAILS_CONFIG, + }; + generate_scratched(ruby, obj, cfg, 0) +} + /// `NOSJ.generate_native(obj, opts)`: the fixed-arity entry kept for /// the Ruby-level wrappers (pretty_generate merges options first). pub fn generate_native( @@ -149,15 +177,18 @@ fn generate_scratched_into( GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() { Ok(mut scratch) => { let scratch = &mut *scratch; - generate_with( - ruby, - obj, - cfg, - cap_hint, - &mut scratch.buf, - &mut scratch.keys, - finish, - ) + let GenScratch { + buf, + keys, + html_keys, + .. + } = scratch; + let keys = if cfg.mode == nosj::emit::EscapeMode::HtmlSafe { + html_keys + } else { + keys + }; + generate_with(ruby, obj, cfg, cap_hint, buf, keys, finish) } Err(_) => { let mut buf = Vec::new(); diff --git a/ext/nosj/src/gen/opts.rs b/ext/nosj/src/gen/opts.rs index 0e23692..401a316 100644 --- a/ext/nosj/src/gen/opts.rs +++ b/ext/nosj/src/gen/opts.rs @@ -16,6 +16,11 @@ pub(super) struct GenConfig { pub(super) start_depth: usize, pub(super) allow_nan: bool, pub(super) strict: bool, + /// ActiveSupport walk semantics: non-native values recurse through + /// as_json instead of splicing to_json, and non-finite floats emit + /// null (Float#as_json parity). Set only by the Rails entry, never + /// from user option hashes. + pub(super) rails: bool, pub(super) mode: EscapeMode, /// Precomputed "any formatting string set": scanning the five /// vectors per call was measurable on tiny documents. @@ -37,6 +42,78 @@ pub(super) static DEFAULT_CONFIG: GenConfig = GenConfig { start_depth: 0, allow_nan: false, strict: false, + rails: false, + mode: EscapeMode::Standard, + pretty: false, +}; + +/// The Rails-encoder configuration for ActiveSupport's default escape +/// flags (HTML entities and JS separators both on, the overwhelmingly +/// common case): escaping is fused into the crate's HtmlSafe kernels, +/// one pass, no post-scan. +pub(super) static RAILS_HTML_SAFE_CONFIG: GenConfig = GenConfig { + indent: Vec::new(), + space: Vec::new(), + space_before: Vec::new(), + object_nl: Vec::new(), + array_nl: Vec::new(), + max_nesting: 100, + start_depth: 0, + allow_nan: false, + strict: false, + rails: true, + mode: EscapeMode::HtmlSafe, + pretty: false, +}; + +/// Rails-encoder configuration with HTML entities on and JS separators +/// off. +pub(super) static RAILS_HTML_ENTITIES_CONFIG: GenConfig = GenConfig { + indent: Vec::new(), + space: Vec::new(), + space_before: Vec::new(), + object_nl: Vec::new(), + array_nl: Vec::new(), + max_nesting: 100, + start_depth: 0, + allow_nan: false, + strict: false, + rails: true, + mode: EscapeMode::HtmlEntities, + pretty: false, +}; + +/// Rails-encoder configuration with JS separators on and HTML entities +/// off. +pub(super) static RAILS_JS_SEPARATORS_CONFIG: GenConfig = GenConfig { + indent: Vec::new(), + space: Vec::new(), + space_before: Vec::new(), + object_nl: Vec::new(), + array_nl: Vec::new(), + max_nesting: 100, + start_depth: 0, + allow_nan: false, + strict: false, + rails: true, + mode: EscapeMode::JsSeparators, + pretty: false, +}; + +/// The Rails-encoder configuration with every escape flag off +/// (encode(escape: false)). Mirrors JSONGemEncoder#stringify, which +/// generates with the json gem's defaults. +pub(super) static RAILS_CONFIG: GenConfig = GenConfig { + indent: Vec::new(), + space: Vec::new(), + space_before: Vec::new(), + object_nl: Vec::new(), + array_nl: Vec::new(), + max_nesting: 100, + start_depth: 0, + allow_nan: false, + strict: false, + rails: true, mode: EscapeMode::Standard, pretty: false, }; @@ -53,6 +130,7 @@ impl Default for GenConfig { start_depth: 0, allow_nan: false, strict: false, + rails: false, mode: EscapeMode::Standard, pretty: false, } diff --git a/ext/nosj/src/gen/ruby.rs b/ext/nosj/src/gen/ruby.rs index 512a251..4b0ff94 100644 --- a/ext/nosj/src/gen/ruby.rs +++ b/ext/nosj/src/gen/ruby.rs @@ -23,6 +23,57 @@ pub(super) fn protected_to_json(v: VALUE) -> Result { magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, to_json_id(), 0) }) } +/// `v.as_json`, protected. Argument-less on purpose: ActiveSupport's +/// JSONGemEncoder#jsonify recursion also calls as_json without +/// options (only the top-level value receives them). +pub(super) fn protected_as_json(v: VALUE) -> Result { + magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, as_json_id(), 0) }) +} + +/// Interned `as_json` method ID, resolved once per process. +fn as_json_id() -> rb_sys::ID { + static AS_JSON: OnceLock = OnceLock::new(); + *AS_JSON.get_or_init(|| unsafe { rb_sys::rb_intern(c"as_json".as_ptr()) } as usize) + as rb_sys::ID +} + +/// Whether `v` is a `JSON::Fragment` (pre-rendered JSON to splice +/// verbatim: the gem accepts fragments even under `strict`, and +/// ActiveSupport's encoder passes them through). The class is resolved +/// lazily and cached only on success, so a json gem loaded after the +/// first generate is still found; a fragment instance existing implies +/// its class does. The cached VALUE is a constant of the JSON module, +/// so it can never be collected. +pub(super) fn is_json_fragment(v: VALUE) -> bool { + use std::sync::atomic::{AtomicUsize, Ordering}; + static FRAGMENT: AtomicUsize = AtomicUsize::new(0); + let mut cls = FRAGMENT.load(Ordering::Relaxed); + if cls == 0 { + cls = resolve_json_fragment(); + if cls == 0 { + return false; + } + FRAGMENT.store(cls, Ordering::Relaxed); + } + unsafe { rb_sys::rb_obj_is_kind_of(v, cls as VALUE) != QFALSE } +} + +fn resolve_json_fragment() -> usize { + unsafe { + let object = rb_sys::rb_cObject; + let json_id = rb_sys::rb_intern(c"JSON".as_ptr()); + if rb_sys::rb_const_defined(object, json_id) == 0 { + return 0; + } + let json = rb_sys::rb_const_get(object, json_id); + let fragment_id = rb_sys::rb_intern(c"Fragment".as_ptr()); + if rb_sys::rb_const_defined(json, fragment_id) == 0 { + return 0; + } + rb_sys::rb_const_get(json, fragment_id) as usize + } +} + /// Encode `v` to UTF-8, protected. `rb_str_encode` raises on /// undefined/invalid conversions, matching the gem, which wraps that /// exception as GeneratorError (`rb_str_export_to_enc` is lenient and diff --git a/ext/nosj/src/gen/walker.rs b/ext/nosj/src/gen/walker.rs index 1b8d1e4..75b77f9 100644 --- a/ext/nosj/src/gen/walker.rs +++ b/ext/nosj/src/gen/walker.rs @@ -3,7 +3,7 @@ //! Compact and pretty modes are one const-generic body, so the compact //! hot path carries no formatting branches. -use nosj::emit::{self, EscapeMode}; +use nosj::emit::{self, copy_short_raw, EscapeMode}; use rb_sys::macros::{ FIX2LONG, FIXNUM_P, FLONUM_P, RARRAY_CONST_PTR, RARRAY_LEN, RB_BUILTIN_TYPE, RHASH_SIZE, STATIC_SYM_P, @@ -14,52 +14,16 @@ use super::errors::GenFail; use super::keys::GenKeyCache; use super::opts::GenConfig; use super::ruby::{ - is_special_const, protected_encode_utf8, protected_to_json, protected_to_s, rstring_bytes, - str_coderange, str_enc_index, to_json_id, utf8_encindexes, CR_7BIT, CR_VALID, QFALSE, QNIL, - QTRUE, + is_json_fragment, is_special_const, protected_as_json, protected_encode_utf8, + protected_to_json, protected_to_s, rstring_bytes, str_coderange, str_enc_index, to_json_id, + utf8_encindexes, CR_7BIT, CR_VALID, QFALSE, QNIL, QTRUE, }; -/// Overlapping-word copy for short runs through a raw pointer (the -/// crate's `copy_small` shape, local because that helper is -/// crate-internal): a size-laddered pair of unaligned loads/stores -/// instead of a libc memmove call, whose per-call overhead measured -/// 42% on tiny-copy-heavy generation. Cached keys over 32 bytes are -/// rare enough for the memcpy fallback. -/// -/// # Safety -/// -/// `n` readable bytes at `src`, `n` writable bytes at `dst`, and the -/// ranges must not overlap. -#[inline(always)] -unsafe fn copy_short_raw(src: *const u8, dst: *mut u8, n: usize) { - /// One overlapping pair: word-size chunks at offset 0 and at - /// `n - size` cover `size..=2*size` bytes; the overlapped middle - /// is written twice with identical data. - macro_rules! word_pair { - ($t:ty) => {{ - const SIZE: usize = size_of::<$t>(); - dst.cast::<$t>() - .write_unaligned(src.cast::<$t>().read_unaligned()); - dst.add(n - SIZE) - .cast::<$t>() - .write_unaligned(src.add(n - SIZE).cast::<$t>().read_unaligned()); - }}; - } - if n >= 16 { - if n <= 32 { - word_pair!(u128); - } else { - std::ptr::copy_nonoverlapping(src, dst, n); - } - } else if n >= 8 { - word_pair!(u64); - } else if n >= 4 { - word_pair!(u32); - } else if n >= 2 { - word_pair!(u16); - } else if n == 1 { - *dst = *src; - } +/// Whether keys escaped under `mode` may be cached: the cached bytes +/// bake in the escape mode, so the scratch keeps one cache per +/// cacheable mode and hands `Gen` the matching one. +pub(super) fn mode_cacheable(mode: EscapeMode) -> bool { + matches!(mode, EscapeMode::Standard | EscapeMode::HtmlSafe) } pub(super) struct Gen<'a> { @@ -69,6 +33,7 @@ pub(super) struct Gen<'a> { pub(super) fail: Option, /// Pre-escaped key cache, borrowed for the whole document (one /// thread-local borrow per generate call instead of one per key). + /// Always the cache matching `cfg.mode` (see [`mode_cacheable`]). pub(super) keys: &'a mut GenKeyCache, } @@ -149,6 +114,11 @@ impl Gen<'_> { emit::write_f64(&mut *self.out, f); return Ok(()); } + if self.cfg.rails { + // ActiveSupport's Float#as_json: non-finite floats are null. + self.out.extend_from_slice(b"null"); + return Ok(()); + } let name = if f.is_nan() { "NaN" } else if f > 0.0 { @@ -166,13 +136,14 @@ impl Gen<'_> { } /// Emit an object key from the pre-escaped cache. Only frozen string - /// keys in Standard escape mode are cacheable: frozen guarantees the - /// content behind the VALUE can't change, and the cached bytes bake in - /// the escape mode. + /// keys in a cacheable escape mode qualify: frozen guarantees the + /// content behind the VALUE can't change, and the cached bytes bake + /// in the escape mode, so each cacheable mode gets its own cache + /// instance from the scratch (see [`mode_cacheable`]). fn emit_key_cached(&mut self, k: VALUE) -> Result<(), ()> { const FL_FREEZE: u64 = rb_sys::ruby_fl_type::RUBY_FL_FREEZE as u64; let frozen = unsafe { (*(k as *const rb_sys::RBasic)).flags } & FL_FREEZE != 0; - if !frozen || self.cfg.mode != EscapeMode::Standard { + if !frozen || !mode_cacheable(self.cfg.mode) { return self.emit_rstring_quoted(k); } if let Some(bytes) = self.keys.get(k) { @@ -190,13 +161,13 @@ impl Gen<'_> { } /// The pre-escaped bytes for `k` when the cache may serve it: - /// frozen string key, Standard escape mode (see [`Gen::emit_key_cached`] - /// for why only that combination is cacheable). An associated fn - /// over the split-out fields so callers keep `self.out` free. + /// frozen string key, cacheable escape mode (see + /// [`Gen::emit_key_cached`]). An associated fn over the split-out + /// fields so callers keep `self.out` free. #[inline(always)] fn cached_key_bytes<'k>(cfg: &GenConfig, keys: &'k GenKeyCache, k: VALUE) -> Option<&'k [u8]> { const FL_FREEZE: u64 = rb_sys::ruby_fl_type::RUBY_FL_FREEZE as u64; - if cfg.mode == EscapeMode::Standard + if mode_cacheable(cfg.mode) && !is_special_const(k) && unsafe { RB_BUILTIN_TYPE(k) } == ruby_value_type::RUBY_T_STRING && unsafe { (*(k as *const rb_sys::RBasic)).flags } & FL_FREEZE != 0 @@ -303,11 +274,20 @@ impl Gen<'_> { } } - /// Non-native type: strict raises; otherwise `to_json` if the object - /// responds (result appended verbatim), else `to_s` as a JSON string, - /// which is exactly what the gem's `Object#to_json` does. - fn emit_fallback(&mut self, raw: VALUE) -> Result<(), ()> { + /// Non-native type: strict raises (except `JSON::Fragment`, which + /// the gem splices even under strict); otherwise `to_json` if the + /// object responds (result appended verbatim), else `to_s` as a + /// JSON string, which is exactly what the gem's `Object#to_json` + /// does. Rails mode recurses through `as_json` instead + /// (JSONGemEncoder#jsonify). + fn emit_fallback(&mut self, raw: VALUE, depth: usize) -> Result<(), ()> { + if self.cfg.rails { + return self.emit_rails_fallback::(raw, depth); + } if self.cfg.strict { + if is_json_fragment(raw) { + return self.splice_to_json(raw); + } let name = unsafe { std::ffi::CStr::from_ptr(rb_sys::rb_obj_classname(raw)) .to_string_lossy() @@ -341,6 +321,64 @@ impl Gen<'_> { } } + /// Splice `raw`'s `to_json` result verbatim: the JSON::Fragment + /// path (pre-rendered JSON, trusted like the gem trusts it). + fn splice_to_json(&mut self, raw: VALUE) -> Result<(), ()> { + match protected_to_json(raw) { + Ok(json) + if !is_special_const(json) + && unsafe { RB_BUILTIN_TYPE(json) } == ruby_value_type::RUBY_T_STRING => + { + self.append_rstring_raw(json); + Ok(()) + } + Ok(_) => { + self.fail = Some(GenFail::Generator( + "JSON::Fragment#to_json did not return a String".to_string(), + )); + Err(()) + } + Err(exc) => { + self.fail = Some(GenFail::Reraise(exc)); + Err(()) + } + } + } + + /// Rails-mode fallback, mirroring JSONGemEncoder#jsonify: + /// fragments splice through (like ActiveSupport passes them to the + /// gem); everything else is asked for its as_json representation + /// (no arguments; only the top-level value receives the encoder + /// options), which is emitted in its place. An as_json returning + /// the receiver would recurse forever, so it raises instead. + fn emit_rails_fallback( + &mut self, + raw: VALUE, + depth: usize, + ) -> Result<(), ()> { + if is_json_fragment(raw) { + return self.splice_to_json(raw); + } + match protected_as_json(raw) { + Ok(json) if json == raw => { + let name = unsafe { + std::ffi::CStr::from_ptr(rb_sys::rb_obj_classname(raw)) + .to_string_lossy() + .into_owned() + }; + self.fail = Some(GenFail::Generator(format!( + "{name}#as_json returned the receiver" + ))); + Err(()) + } + Ok(json) => self.emit_value::(json, depth), + Err(exc) => { + self.fail = Some(GenFail::Reraise(exc)); + Err(()) + } + } + } + fn nesting_check(&mut self, inner: usize) -> Result<(), ()> { if self.cfg.max_nesting > 0 && inner > self.cfg.max_nesting { self.fail = Some(GenFail::Nesting(self.cfg.max_nesting)); @@ -601,7 +639,7 @@ impl Gen<'_> { let s = unsafe { rb_sys::rb_sym2str(raw) }; self.emit_rstring_quoted(s) } - _ => self.emit_fallback(raw), + _ => self.emit_fallback::(raw, depth), }; } if FIXNUM_P(raw) { @@ -633,6 +671,6 @@ impl Gen<'_> { let s = unsafe { rb_sys::rb_sym2str(raw) }; return self.emit_rstring_quoted(s); } - self.emit_fallback(raw) + self.emit_fallback::(raw, depth) } } diff --git a/ext/nosj/src/lib.rs b/ext/nosj/src/lib.rs index 31f646d..edf48cf 100644 --- a/ext/nosj/src/lib.rs +++ b/ext/nosj/src/lib.rs @@ -70,6 +70,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> { lazy_class.define_method("__size", method!(lazy::lazy_size, 0))?; lazy_class.define_method("__children", method!(lazy::lazy_children, 0))?; module.define_singleton_method("generate_native", method!(gen::generate_native, 2))?; + module.define_singleton_method( + "generate_rails_native", + method!(gen::generate_rails_native, 3), + )?; // `generate` itself is native and variadic: the json gem routes // its `generate` through a Ruby frame into C, so skipping our own // forwarder frame is a straight per-call win on small documents. diff --git a/lib/nosj/json.rb b/lib/nosj/json.rb index ccf80f8..af520e2 100644 --- a/lib/nosj/json.rb +++ b/lib/nosj/json.rb @@ -6,7 +6,7 @@ # # reroutes JSON.parse, JSON.generate, JSON.pretty_generate and JSON.dump # through NOSJ whenever the requested options fall within NOSJ's -# supported set, and falls back to the original json implementation for +# supported set, and falls back to gem json's own implementation for # everything else (create_additions, object_class/array_class, # decimal_class, on_load procs, JSON::State instances, IO arguments). # Entry points built on JSON.parse (JSON.load, JSON.parse!, @@ -30,8 +30,11 @@ module NOSJ # Implementation detail of `require "nosj/json"`. # @private module JSONDropIn + # quirks_mode rides the fast path because NOSJ.parse is always + # quirks-mode (top-level scalars parse) and ignores the key; Rails + # 7.x passes it from ActiveSupport::JSON.decode. PARSE_OPTS = %i[symbolize_names freeze max_nesting allow_nan - allow_trailing_comma].freeze + allow_trailing_comma quirks_mode].freeze GENERATE_OPTS = %i[indent space space_before object_nl array_nl max_nesting allow_nan ascii_only script_safe escape_slash strict depth @@ -41,7 +44,7 @@ module JSONDropIn # The fast path handles nil or a plain Hash whose every key NOSJ # implements; anything else (JSON::State, exotic options, string - # keys) belongs to the original implementation. + # keys) belongs to gem json. def supported?(opts, allowed) return true if opts.nil? return false unless opts.instance_of?(Hash) @@ -50,6 +53,25 @@ def supported?(opts, allowed) end def parse(source, opts) + # NOSJ.parse is deliberately strict about encodings (json-3.0 + # semantics), but the drop-in must match the installed gem, which + # accepts more. BINARY strings holding valid UTF-8 are the big + # real-world case: Rack delivers request bodies as BINARY, so + # Rails JSON params come through here. Retagging a dup is cheap + # (copy-on-write bytes), and the validity scan is memoized + # coderange the parse would compute anyway. Anything else + # non-UTF-8 (UTF-16, ...) belongs to gem json, which transcodes. + if source.is_a?(String) + case source.encoding + when Encoding::UTF_8, Encoding::US_ASCII + # the fast path as-is + when Encoding::BINARY + utf8 = source.dup.force_encoding(Encoding::UTF_8) + source = utf8 if utf8.valid_encoding? + else + return ::JSON.nosj_original_parse(source, **(opts || {})) + end + end NOSJ.parse(source, opts) rescue RuntimeError => e raise ::JSON::ParserError, e.message @@ -103,9 +125,9 @@ def pretty_generate(obj, opts = nil) def dump(obj, an_io = nil, limit = nil, kwargs = nil) # Fast path for the common shapes, dump(obj) and dump(obj, opts - # hash), mirroring the gem: dump defaults merged under the + # hash), mirroring gem json: dump defaults merged under the # user's options, NestingError surfaced as ArgumentError. IO and - # limit arguments take the original implementation. + # limit arguments take gem json's own dump. if limit.nil? && kwargs.nil? && (an_io.nil? || an_io.instance_of?(Hash)) opts = _dump_default_options opts = opts.merge(an_io) if an_io diff --git a/lib/nosj/rails.rb b/lib/nosj/rails.rb new file mode 100644 index 0000000..5bd1694 --- /dev/null +++ b/lib/nosj/rails.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Rails mode: `require "nosj/rails"` accelerates a Rails application in +# both directions. +# +# - It installs {NOSJ::RailsEncoder} as ActiveSupport's JSON encoder +# (the official +ActiveSupport::JSON::Encoding.json_encoder+ seam, the +# same one Oj's Rails mode uses). That captures every encode a Rails +# app performs through +obj.to_json+, +render json:+, and +# +ActiveSupport::JSON.encode+: the object tree is walked natively, +# values that are not JSON-native recurse through +as_json+ exactly +# like ActiveSupport's own encoder, and non-finite floats encode as +# +null+ (+Float#as_json+ parity). +# - It loads the `nosj/json` drop-in, so +JSON.parse+ (and with it +# +ActiveSupport::JSON.decode+ and JSON request-body parsing) takes +# the fast path. +# +# In a Rails Gemfile: +# +# gem "nosj", require: "nosj/rails" +# +# Known divergence: +JSON::Fragment+ values raise instead of splicing +# raw JSON (fragments are unsupported gem-wide). + +require "nosj/json" +require "active_support" +require "active_support/json" + +module NOSJ + # ActiveSupport JSON encoder backed by nosj: the interface of + # ActiveSupport's +JSONGemEncoder+ (accepts the options hash, encodes + # one value), with the tree walk, generation, AND the HTML/JS-safety + # escape pass running natively (a byte scan instead of ActiveSupport's + # Ruby regex post-pass), honoring +escape_html_entities_in_json+ (and, + # where present, +escape_js_separators_in_json+ and the per-call + # +escape:+ / +escape_html_entities:+ options). + class RailsEncoder + # The escape_js_separators_in_json knob is newer than the Rails + # versions we support; its presence is fixed at load time (only its + # value can change at runtime). Absent, ActiveSupport always + # escapes the JS separators. + HAS_JS_SEPARATORS_KNOB = + ActiveSupport::JSON::Encoding.respond_to?(:escape_js_separators_in_json) + private_constant :HAS_JS_SEPARATORS_KNOB + + attr_reader :options + + def initialize(options = nil) + @options = options || {} + end + + def encode(value) + value = value.as_json(@options.dup) unless @options.empty? + if @options.fetch(:escape, true) + escape_html = @options.fetch(:escape_html_entities) do + ActiveSupport::JSON::Encoding.escape_html_entities_in_json + end + NOSJ.generate_rails_native(value, !!escape_html, escape_js_separators?) + else + NOSJ.generate_rails_native(value, false, false) + end + end + + private + + def escape_js_separators? + !HAS_JS_SEPARATORS_KNOB || + ActiveSupport::JSON::Encoding.escape_js_separators_in_json + end + end + + ActiveSupport::JSON::Encoding.json_encoder = RailsEncoder +end diff --git a/nosj.gemspec b/nosj.gemspec index d545568..175ba6c 100644 --- a/nosj.gemspec +++ b/nosj.gemspec @@ -15,8 +15,9 @@ Gem::Specification.new do |spec| "with per-platform PGO, partial parsing (JSON Pointer, single and batch), " \ "lazy documents that parse a value only when you touch it, file APIs that " \ "parse, generate, and query files natively (memory-mapped, so unread pages " \ - "never leave the disk), allocation-free validation, and a one-line JSON " \ - "module drop-in." + "never leave the disk), allocation-free validation, a one-line JSON " \ + "module drop-in, and a Rails mode that plugs into ActiveSupport's " \ + "encoder seam." spec.homepage = "https://github.com/yaroslav/nosj-ruby" spec.license = "MIT" spec.required_ruby_version = ">= 3.3.0" diff --git a/sig/nosj.rbs b/sig/nosj.rbs index 64a90be..0076417 100644 --- a/sig/nosj.rbs +++ b/sig/nosj.rbs @@ -101,6 +101,16 @@ module NOSJ alias to_s inspect end + # Defined by `require "nosj/rails"`, which also installs it as + # ActiveSupport::JSON::Encoding.json_encoder. + class RailsEncoder + attr_reader options: Hash[Symbol, untyped] + + def initialize: (?Hash[Symbol, untyped]? options) -> void + + def encode: (untyped value) -> String + end + # Defined by `require "nosj/multi_json"`. The runtime superclass is # multi_json's Adapter (whose namespace differs across multi_json # versions), so it is not declared here. diff --git a/spec/generate_spec.rb b/spec/generate_spec.rb index 92ab4d3..f3e0578 100644 --- a/spec/generate_spec.rb +++ b/spec/generate_spec.rb @@ -93,4 +93,12 @@ def expect_gem_parity(obj, opts = nil) expect(NOSJ.parse(NOSJ.generate(obj))).to eq(obj), "round-trip mismatch: #{File.basename(f)}" end end + + it "splices JSON::Fragment like the gem, in default and strict modes" do + value = {"cached" => JSON::Fragment.new('{"pre":"rendered"}')} + expect_gem_parity(value) + expect(NOSJ.generate(value, strict: true)) + .to eq(JSON.generate(value, strict: true)) + expect(NOSJ.generate(value)).to eq('{"cached":{"pre":"rendered"}}') + end end diff --git a/spec/json_dropin_spec.rb b/spec/json_dropin_spec.rb index 5da3ed3..538abac 100644 --- a/spec/json_dropin_spec.rb +++ b/spec/json_dropin_spec.rb @@ -103,6 +103,22 @@ class MyHash < Hash; end RUBY end + it "accepts the encodings the gem accepts (Rack bodies are BINARY)" do + expect_ok(<<~RUBY) + require "nosj/json" + body = '{"user":"ada","n":1.5}'.b + raise "binary" unless JSON.parse(body) == {"user" => "ada", "n" => 1.5} + utf16 = '{"a":1}'.encode(Encoding::UTF_16LE) + raise "utf16 fallback" unless JSON.parse(utf16) == JSON.nosj_original_parse(utf16) + begin + JSON.parse("\\xFF\\xFE{}".b) + raise "no error" + rescue JSON::ParserError + end + puts "ALL-OK" + RUBY + end + it "provides a MultiJson adapter" do expect_ok(<<~RUBY) require "nosj/multi_json" diff --git a/spec/rails_integration_spec.rb b/spec/rails_integration_spec.rb new file mode 100644 index 0000000..11a8fcf --- /dev/null +++ b/spec/rails_integration_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# Full-stack Rails integration: a real ActionDispatch route set and +# ActionController::API controller, driven through the Rack interface. +# JSON request bodies enter through ActionDispatch's parameter parsing +# (ActiveSupport::JSON.decode -> the nosj/json drop-in) and responses +# leave through render json: (Object#to_json -> the nosj Rails +# encoder). Every example runs the same requests before and after +# `require "nosj/rails"` in one subprocess and compares the raw +# response bytes. +RSpec.describe "nosj/rails ActionDispatch integration" do + def expect_ok(script) + out = IO.popen( + [RbConfig.ruby, "-I", File.expand_path("../lib", __dir__), "-e", script], + err: [:child, :out], &:read + ) + expect($?.success?).to be(true), out + expect(out).to include("ALL-OK") + end + + def app_prelude + <<~RUBY + require "action_controller" + + class ApiController < ActionController::API + def echo + render json: {"params" => request.request_parameters, "q" => params[:q]} + end + + def show + render json: { + "time" => Time.at(0).utc, + "floats" => [2.5, Float::NAN, Float::INFINITY], + "html" => "", + "sym" => :ok, + "deep" => {"arr" => [1, [2, [3, {"k" => nil}]]]}, + "model" => Class.new { def as_json(_ = nil) = {"custom" => true} }.new + } + end + end + + ROUTES = ActionDispatch::Routing::RouteSet.new + ROUTES.draw do + post "/echo", to: "api#echo" + get "/show", to: "api#show" + end + + def hit(method, path, body = nil) + env = Rack::MockRequest.env_for( + path, + method: method, + input: body, + "CONTENT_TYPE" => (body ? "application/json" : nil) + ) + status, _headers, response = ROUTES.call(env) + chunks = +"" + response.each { |c| chunks << c } + [status, chunks] + rescue => e + [:raised, e.class.name] + end + RUBY + end + + it "serves byte-identical responses through the full request cycle" do + expect_ok(app_prelude + <<~'RUBY') + requests = [ + [:get, "/show", nil], + [:post, "/echo?q=1", '{"user":{"name":"ada","tags":["x","y"]},"n":12345678901234567890,"f":1.5}'], + [:post, "/echo", '{"unicode":"проверка 
 done","html":"<&>"}'], + [:post, "/echo", '{"deep":' + ("[" * 50) + "1" + ("]" * 50) + "}"] + ] + stock = requests.map { |r| hit(*r) } + stock.each { |status, _| raise "stock request failed: #{status}" unless status == 200 } + + require "nosj/rails" + + requests.each_with_index do |r, i| + mine = hit(*r) + unless mine == stock[i] + raise "MISMATCH on #{r[1]}:\n stock: #{stock[i].inspect}\n nosj: #{mine.inspect}" + end + end + puts "ALL-OK" + RUBY + end + + it "actually routes through nosj in both directions" do + expect_ok(app_prelude + <<~'RUBY') + require "nosj/rails" + + raise "encoder not installed" unless + ActiveSupport::JSON::Encoding.json_encoder == NOSJ::RailsEncoder + raise "drop-in not installed" unless JSON.respond_to?(:nosj_original_parse, true) + + status, body = hit(:post, "/echo", '{"user":"ada"}') + raise "status #{status}" unless status == 200 + raise "params did not round-trip: #{body}" unless + body.include?('"params":{"user":"ada"}') + + status, body = hit(:get, "/show") + raise "status #{status}" unless status == 200 + raise "non-finite floats" unless body.include?("[2.5,null,null]") + raise "html escaping" unless body.include?('\\u003cscript\\u003e') + raise "as_json model" unless body.include?('"model":{"custom":true}') + puts "ALL-OK" + RUBY + end + + it "propagates malformed request bodies exactly like stock" do + expect_ok(app_prelude + <<~'RUBY') + bad = '{"broken":' + stock = hit(:post, "/echo", bad) + raise "stock did not raise: #{stock.inspect}" unless stock[0] == :raised + + require "nosj/rails" + + mine = hit(:post, "/echo", bad) + unless mine == stock + raise "error mismatch: stock #{stock.inspect} vs nosj #{mine.inspect}" + end + puts "ALL-OK" + RUBY + end +end diff --git a/spec/rails_spec.rb b/spec/rails_spec.rb new file mode 100644 index 0000000..17be671 --- /dev/null +++ b/spec/rails_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# Installing the encoder mutates ActiveSupport globally (and loading +# activesupport pollutes core classes), so every assertion runs in a +# subprocess, differentially against ActiveSupport's own encoder. +RSpec.describe "require 'nosj/rails'" do + def expect_ok(script) + out = IO.popen( + [RbConfig.ruby, "-I", File.expand_path("../lib", __dir__), "-e", script], + err: [:child, :out], &:read + ) + expect($?.success?).to be(true), out + expect(out).to include("ALL-OK") + end + + it "matches ActiveSupport's encoder byte-for-byte across the battery" do + expect_ok(<<~'RUBY') + require "active_support" + require "active_support/json" + require "bigdecimal" + + custom = Class.new { def as_json(_ = nil) = {"custom" => true} }.new + # Real-world escape-heavy content (tweets are full of & and <) + # pins the native escape pass; the subprocess inherits the repo + # root as cwd. + corpus = %w[benchmark/twitter.json benchmark/activitypub.json] + .select { |f| File.exist?(f) }.map { |f| JSON.parse(File.read(f)) } + fixtures = corpus + [ + {"a" => [1, true, nil], "sym" => :sym, "f" => 2.5}, + {"html" => ""}, + ["line" + 0x2028.chr(Encoding::UTF_8) + "sep" + 0x2029.chr(Encoding::UTF_8)], + [Float::NAN, Float::INFINITY, -Float::INFINITY, 1.5], + Time.at(0).utc, Date.new(2026, 7, 16), BigDecimal("1.5"), + {1 => "int key", nil => "nil key"}, + custom, [custom], + "plain", 42, nil, true, + {"deep" => {"er" => {"est" => [[[1]]]}}} + ] + + stock = fixtures.map { |v| ActiveSupport::JSON.encode(v) } + stock_to_json = fixtures.map(&:to_json) + stock_opts = ActiveSupport::JSON.encode({"a" => 1, "b" => 2}, only: "a") + + require "nosj/rails" + + fixtures.each_with_index do |v, i| + mine = ActiveSupport::JSON.encode(v) + raise "encode mismatch at #{i}: #{stock[i].inspect} vs #{mine.inspect}" unless stock[i] == mine + raise "to_json mismatch at #{i}" unless v.to_json == stock_to_json[i] + end + unless ActiveSupport::JSON.encode({"a" => 1, "b" => 2}, only: "a") == stock_opts + raise "options not forwarded to as_json" + end + puts "ALL-OK" + RUBY + end + + it "matches stock for SafeBuffer strings and time_precision config" do + expect_ok(<<~'RUBY') + require "active_support" + require "active_support/json" + require "active_support/core_ext/string/output_safety" + + fixtures = [ + {"safe" => "bold".html_safe, "plain" => "bold"}, + {"t" => Time.at(0, 123456, :usec).utc} + ] + ActiveSupport::JSON::Encoding.time_precision = 6 + stock = fixtures.map { |v| ActiveSupport::JSON.encode(v) } + + require "nosj/rails" + + fixtures.each_with_index do |v, i| + mine = ActiveSupport::JSON.encode(v) + raise "mismatch #{i}: #{stock[i].inspect} vs #{mine.inspect}" unless mine == stock[i] + end + # The config must actually be honored (6 fractional digits), not + # just match stock. + raise "precision" unless ActiveSupport::JSON.encode(fixtures[1]) =~ /\.\d{6}/ + puts "ALL-OK" + RUBY + end + + it "honors the escape_html_entities_in_json config both ways" do + expect_ok(<<~RUBY) + require "active_support" + require "active_support/json" + + probe = {"h" => "<&>"} + ActiveSupport::JSON::Encoding.escape_html_entities_in_json = false + stock_off = ActiveSupport::JSON.encode(probe) + ActiveSupport::JSON::Encoding.escape_html_entities_in_json = true + stock_on = ActiveSupport::JSON.encode(probe) + + require "nosj/rails" + + raise "escaped mismatch" unless ActiveSupport::JSON.encode(probe) == stock_on + ActiveSupport::JSON::Encoding.escape_html_entities_in_json = false + raise "unescaped mismatch" unless ActiveSupport::JSON.encode(probe) == stock_off + raise "not actually unescaped" unless ActiveSupport::JSON.encode(probe).include?("<&>") + puts "ALL-OK" + RUBY + end + + it "splices JSON::Fragment like ActiveSupport does" do + expect_ok(<<~'RUBY') + require "active_support" + require "active_support/json" + require "json" + + value = {"cached" => JSON::Fragment.new('{"pre":"rendered"}')} + stock = ActiveSupport::JSON.encode(value) + + require "nosj/rails" + + mine = ActiveSupport::JSON.encode(value) + raise "fragment mismatch: #{stock.inspect} vs #{mine.inspect}" unless stock == mine + raise "not spliced" unless mine == '{"cached":{"pre":"rendered"}}' + puts "ALL-OK" + RUBY + end + + it "routes ActiveSupport::JSON.decode through the drop-in fast path" do + expect_ok(<<~RUBY) + require "active_support" + require "active_support/json" + require "nosj/rails" + + parsed = ActiveSupport::JSON.decode('{"a":[1,true],"n":1.5}') + raise "decode" unless parsed == {"a" => [1, true], "n" => 1.5} + # Rails 7.x passes quirks_mode: true; the fast path must accept it. + raise "quirks" unless JSON.parse("2", quirks_mode: true) == 2 + puts "ALL-OK" + RUBY + end + + it "raises instead of looping when as_json returns the receiver" do + expect_ok(<<~RUBY) + require "active_support" + require "active_support/json" + require "nosj/rails" + + selfish = Class.new { def as_json(_ = nil) = self }.new + begin + ActiveSupport::JSON.encode(selfish) + raise "no error raised" + rescue NOSJ::GeneratorError => e + raise "wrong message" unless e.message.include?("as_json returned the receiver") + end + puts "ALL-OK" + RUBY + end +end From 290b3029a4cc89e34d1df6130225d5f89e7924f5 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 17 Jul 2026 10:57:34 +0300 Subject: [PATCH 2/2] Fix CI after the Rails mode push - Gemfile.lock lost every non-mac platform in a local bundle install (bundler exit 16 across the matrix and Lint); restore ruby, linux glibc/musl on both arches, mingw-ucrt, and darwin. - ActiveSupport before 8.1 predates JSON::Fragment and encodes its instance variables; nosj splices the fragment anyway, as current Rails does. Document the deliberate divergence in nosj/rails and make the spec demand byte-equality with stock only where stock itself splices. --- Gemfile.lock | 20 ++++++++++++++++++++ lib/nosj/rails.rb | 7 +++++-- spec/rails_spec.rb | 12 +++++++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9009656..8cff177 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -61,12 +61,26 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) memory_profiler (1.1.0) + mini_portile2 (2.8.9) minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) multi_json (1.21.1) + nokogiri (1.19.4) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.4-x64-mingw-ucrt) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) oj (3.17.3) bigdecimal (>= 3.0) ostruct (>= 0.2) @@ -160,8 +174,14 @@ GEM yard (0.9.45) PLATFORMS + aarch64-linux + aarch64-linux-musl arm64-darwin-24 arm64-darwin-25 + ruby + x64-mingw-ucrt + x86_64-linux + x86_64-linux-musl DEPENDENCIES actionpack diff --git a/lib/nosj/rails.rb b/lib/nosj/rails.rb index 5bd1694..26417ac 100644 --- a/lib/nosj/rails.rb +++ b/lib/nosj/rails.rb @@ -19,8 +19,11 @@ # # gem "nosj", require: "nosj/rails" # -# Known divergence: +JSON::Fragment+ values raise instead of splicing -# raw JSON (fragments are unsupported gem-wide). +# +JSON::Fragment+ values splice their pre-rendered JSON, like current +# ActiveSupport. On older ActiveSupport (before its encoder learned +# about fragments) stock encoding dumps the fragment's instance +# variables instead; there this encoder deliberately diverges in favor +# of real splicing. require "nosj/json" require "active_support" diff --git a/spec/rails_spec.rb b/spec/rails_spec.rb index 17be671..0ace9aa 100644 --- a/spec/rails_spec.rb +++ b/spec/rails_spec.rb @@ -102,20 +102,26 @@ def expect_ok(script) RUBY end - it "splices JSON::Fragment like ActiveSupport does" do + it "splices JSON::Fragment (matching ActiveSupport wherever it splices)" do expect_ok(<<~'RUBY') require "active_support" require "active_support/json" require "json" + spliced = '{"cached":{"pre":"rendered"}}' value = {"cached" => JSON::Fragment.new('{"pre":"rendered"}')} stock = ActiveSupport::JSON.encode(value) require "nosj/rails" mine = ActiveSupport::JSON.encode(value) - raise "fragment mismatch: #{stock.inspect} vs #{mine.inspect}" unless stock == mine - raise "not spliced" unless mine == '{"cached":{"pre":"rendered"}}' + raise "not spliced: #{mine.inspect}" unless mine == spliced + # Older ActiveSupport predates fragments and dumps the ivars + # instead; the deliberate divergence there is documented in + # nosj/rails. Where stock splices, outputs must match exactly. + if stock == spliced || !stock.include?("json") + raise "fragment mismatch: #{stock.inspect} vs #{mine.inspect}" unless stock == mine + end puts "ALL-OK" RUBY end