diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4d6ef44..19aa5c3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,52 +6,18 @@ jobs: build: strategy: matrix: - ruby_version: ['3.0', '2.7', '2.6'] - - gemfile: - - gemfiles/rails_61.gemfile - - gemfiles/rails_60.gemfile - - include: - - ruby_version: ruby-head - gemfile: gemfiles/rails_edge.gemfile - allow_failures: 'true' - - ruby_version: '3.0' - gemfile: gemfiles/rails_edge.gemfile - allow_failures: 'true' - - - ruby_version: '2.7' - gemfile: gemfiles/rails_edge.gemfile - allow_failures: 'true' - - - ruby_version: '2.6' - gemfile: gemfiles/rails_52.gemfile - - ruby_version: '2.6' - gemfile: gemfiles/rails_51.gemfile - - ruby_version: '2.6' - gemfile: gemfiles/rails_50.gemfile - - ruby_version: '2.6' - gemfile: gemfiles/rails_42.gemfile - bundler_version: '1' - - - ruby_version: '2.5' - gemfile: gemfiles/rails_52.gemfile - - ruby_version: '2.5' - gemfile: gemfiles/rails_42.gemfile - bundler_version: '1' + ruby: ['3.2', '3.3', '3.4', "4.0"] + rails: ["7.1", "7.2", "8.0", "8.1"] runs-on: ubuntu-latest env: - BUNDLE_GEMFILE: ${{ matrix.gemfile }} + BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails }}.gemfile steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: - ruby-version: ${{ matrix.ruby_version }} - bundler: ${{ matrix.bundler_version }} + ruby-version: ${{ matrix.ruby }} bundler-cache: true - continue-on-error: ${{ matrix.allow_failures == 'true' }} - run: bundle exec rake - continue-on-error: ${{ matrix.allow_failures == 'true' }} diff --git a/.github/workflows/mend.yml b/.github/workflows/mend.yml deleted file mode 100644 index b05ec28..0000000 --- a/.github/workflows/mend.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: mend -permissions: - contents: read - id-token: write -on: - schedule: - - cron: '0 8 * * 1' - -jobs: - scan-code: - runs-on: ubuntu-20.04 - steps: - - name: Checkout mend action - uses: actions/checkout@v3 - with: - repository: aktsk/mend-action - path: ./.github/actions/mend-action - ssh-key: ${{ secrets.MEND_CHECKOUT_KEY }} - - name: Generate credential of GCP - uses: 'google-github-actions/auth@v1' - with: - workload_identity_provider: ${{ secrets.MEND_GCP_PROVIDER_ID }} - service_account: ${{ secrets.MEND_SERVICE_ACCOUNT }} - - name: Scan repo - uses: ./.github/actions/mend-action - with: - mend_api_key: ${{ secrets.MEND_API_KEY }} - mend_user_key: ${{ secrets.MEND_USER_KEY }} - slack_token: ${{ secrets.MEND_SLACK_TOKEN }} diff --git a/.gitignore b/.gitignore index dd48f47..ca4f590 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ gemfiles/*.lock /spec/reports/ /tmp/ /test/dummy_app/log/* +/test/dummy_app/tmp/* +/vendor/bundle diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e6c9aa3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. + +## [Unreleased] + +## [1.0.0] - 2026-01-28 +### Breaking +- Templates are no longer executed by invoking lambdas directly; they are compiled into methods. + +### Changed +- Cache template methods by code hash to reduce Ruby method cache invalidation. +- Use a renderer subclass that includes helpers instead of extending instances. + +### Fixed +- Rename the config key `cache_enabled` to `template_cache_enabled`. +- Add `/vendor/bundle` to `.gitignore`. + +## [0.1.0] - 2025-09-10 +### Changed +- Default JSON serializer switched from Oj to ActiveSupport::JSON to better align with Rails defaults. +- Development dependencies refreshed and benchmark script fixed for the latest Ruby/Rails stacks. +- README formatting and examples improved for clarity. + +### Fixed +- Resolved `MissingTemplate` errors introduced by the Rails 8 upgrade. +- Added coverage for rendering when template/action names differ to avoid regressions. + +## [0.0.0] - 2021-11-02 +### Added +- Initial SimpleJson renderer, templates (`.simple_json.rb` lambdas), and `SimpleJson::SimpleJsonRenderable` integration. +- Template caching toggle and configurable template paths. +- Rails generator hooks and dummy app scaffolding for getting started. +- Migration helpers for comparing SimpleJson output against existing Jbuilder views. diff --git a/README.md b/README.md index 673fb55..0c2960a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # SimpleJson + A simple & fast solution for Rails JSON rendering. ## Get started + In Gemfile + ```ruby gem 'simple_json' ``` In controller + ```ruby class ApplicationController < ActionController::Base include SimpleJson::SimpleJsonRenderable @@ -44,10 +48,13 @@ That's all! Have fun! ## Special thanks + This project is built on work of [jb](https://github.com/amatsuda/jb). ## Template Syntax -SimpleJson templates are simply lambda objects that return data(Hashes or Arrays) for json. + +SimpleJson templates should be written in Ruby lambda format. The template code is converted into a method and then invoked to produce data (Hashes or Arrays) for JSON. + ```ruby -> { { @@ -55,7 +62,9 @@ SimpleJson templates are simply lambda objects that return data(Hashes or Arrays } } ``` + When no parameters specified, `-> {` and `}` can be omitted. + ```ruby { key: @value, @@ -83,6 +92,7 @@ Use `partial!` method to call another template in template. Note that path is al ``` Cache helpers of simple_json is similar to jbuilder. + ```ruby cache! key, options do data_to_cache @@ -90,6 +100,7 @@ end ``` Cache helpers uses `Rails.cache` to cache, so array keys, expirations are available. Make sure `perform_caching` is enabled. + ```ruby cache! [key1, key2], expires_in: 10.minutes do data_to_cache @@ -97,6 +108,7 @@ end ``` `cache_if!` is also available + ```ruby cache_if! boolean, key1, options do data_to_cache @@ -104,151 +116,153 @@ end ``` You can set key_prefix for caching like this + ```ruby SimpleJson.cache_key_prefix = "MY_PREFIX" ``` ## Configurations + Load all templates on boot. (For production) Templates loaded will not load again, so it is not recommended in development environment. + ```ruby # config/environments/production.rb SimpleJson.enable_template_cache ``` The default path for templates is `app/views`, you can change it by + ```ruby SimpleJson.template_paths.append("app/simple_jsons") # or SimpleJson.template_paths=["app/views", "app/simple_jsons"] ``` + Note that these paths should not be eager loaded cause using .rb as suffix. -SimpleJson uses Oj as json serializer by default. Modules with `#encode` and `#decode` method can be used here. +SimpleJson uses ActiveSupport::JSON as the default JSON serializer. If you want to change to a module that has `#encode` and `#decode` methods, such as the Oj gem, you can do so as follows. + ```ruby -SimpleJson.json_module = ActiveSupport::JSON + +# define Custom Json class +# ex. config/initializers/simple_json/json.rb +module SimpleJson + module Json + class Oj + def self.encode(json) + ::Oj.dump(json, mode: :rails) + end + + def self.decode(json_string) + ::Oj.load(json_string, mode: :rails) + end + end + end +end + +SimpleJson.json_module = SimpleJson::Json::Oj ``` ## The Generator + SimpleJson extends the default Rails scaffold generator and adds some simple_json templates. If you don't need them, please configure like so. + ```rb Rails.application.config.generators.simple_json false ``` ## Benchmarks + Here're the results of a benchmark (which you can find [here](https://github.com/aktsk/simple_json/blob/master/test/dummy_app/app/controllers/benchmarks_controller.rb) in this repo) rendering a collection to JSON. -### RAILS_ENV=development ``` % ./bin/benchmark.sh -* Rendering 10 partials via render_partial -Warming up -------------------------------------- - jb 257.000 i/100ms - jbuilder 108.000 i/100ms - simple_json 2.039k i/100ms -Calculating ------------------------------------- - jb 2.611k (± 7.1%) i/s - 13.107k in 5.046110s - jbuilder 1.084k (± 3.5%) i/s - 5.508k in 5.088845s - simple_json 20.725k (± 4.4%) i/s - 103.989k in 5.026914s - -Comparison: - simple_json: 20725.5 i/s - jb: 2610.5 i/s - 7.94x (± 0.00) slower - jbuilder: 1083.8 i/s - 19.12x (± 0.00) slower - - -* Rendering 100 partials via render_partial -Warming up -------------------------------------- - jb 88.000 i/100ms - jbuilder 14.000 i/100ms - simple_json 290.000 i/100ms -Calculating ------------------------------------- - jb 928.202 (± 5.0%) i/s - 4.664k in 5.037314s - jbuilder 137.980 (± 6.5%) i/s - 700.000 in 5.094658s - simple_json 2.931k (± 5.2%) i/s - 14.790k in 5.060707s - -Comparison: - simple_json: 2931.1 i/s - jb: 928.2 i/s - 3.16x (± 0.00) slower - jbuilder: 138.0 i/s - 21.24x (± 0.00) slower - +SimpleJson Benchmark +ruby: 4.0.1 +rails: 8.1.1 +json: 2.15.2 +oj: 3.16.11 +---------------------- -* Rendering 1000 partials via render_partial +* Rendering 10 partials via render_to_string +ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [arm64-darwin24] Warming up -------------------------------------- - jb 11.000 i/100ms - jbuilder 1.000 i/100ms - simple_json 29.000 i/100ms + jb 23.000 i/100ms + jbuilder 30.000 i/100ms + simple_json(oj) 40.000 i/100ms +simple_json(AS::json) + 46.000 i/100ms Calculating ------------------------------------- - jb 106.150 (± 5.7%) i/s - 539.000 in 5.094255s - jbuilder 13.012 (± 7.7%) i/s - 65.000 in 5.054016s - simple_json 271.683 (± 5.2%) i/s - 1.363k in 5.030646s + jb 298.518 (±23.4%) i/s (3.35 ms/i) - 1.426k in 5.019370s + jbuilder 255.925 (± 4.3%) i/s (3.91 ms/i) - 1.290k in 5.052973s + simple_json(oj) 270.192 (± 3.7%) i/s (3.70 ms/i) - 1.360k in 5.039635s +simple_json(AS::json) + 297.476 (±10.1%) i/s (3.36 ms/i) - 1.518k in 5.145803s Comparison: - simple_json: 271.7 i/s - jb: 106.1 i/s - 2.56x (± 0.00) slower - jbuilder: 13.0 i/s - 20.88x (± 0.00) slower -``` -### RAILS_ENV=production -``` -% RAILS_ENV=production ./bin/benchmark.sh + jb: 298.5 i/s +simple_json(AS::json): 297.5 i/s - same-ish: difference falls within error + simple_json(oj): 270.2 i/s - same-ish: difference falls within error + jbuilder: 255.9 i/s - same-ish: difference falls within error -* Rendering 10 partials via render_partial -Warming up -------------------------------------- - jb 246.000 i/100ms - jbuilder 97.000 i/100ms - simple_json 1.957k i/100ms -Calculating ------------------------------------- - jb 2.611k (± 4.1%) i/s - 13.038k in 5.002304s - jbuilder 972.031 (± 4.7%) i/s - 4.850k in 5.001200s - simple_json 20.383k (± 3.8%) i/s - 101.764k in 4.999989s -Comparison: - simple_json: 20382.8 i/s - jb: 2611.3 i/s - 7.81x (± 0.00) slower - jbuilder: 972.0 i/s - 20.97x (± 0.00) slower - - -* Rendering 100 partials via render_partial +* Rendering 100 partials via render_to_string +ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [arm64-darwin24] Warming up -------------------------------------- - jb 90.000 i/100ms - jbuilder 11.000 i/100ms - simple_json 280.000 i/100ms + jb 19.000 i/100ms + jbuilder 14.000 i/100ms + simple_json(oj) 15.000 i/100ms +simple_json(AS::json) + 26.000 i/100ms Calculating ------------------------------------- - jb 883.446 (± 4.8%) i/s - 4.410k in 5.003438s - jbuilder 119.932 (± 8.3%) i/s - 605.000 in 5.085382s - simple_json 2.886k (± 4.2%) i/s - 14.560k in 5.054327s + jb 186.051 (±12.9%) i/s (5.37 ms/i) - 912.000 in 5.075117s + jbuilder 144.279 (± 2.1%) i/s (6.93 ms/i) - 728.000 in 5.048538s + simple_json(oj) 159.254 (± 1.9%) i/s (6.28 ms/i) - 810.000 in 5.088178s +simple_json(AS::json) + 249.690 (± 6.0%) i/s (4.00 ms/i) - 1.248k in 5.017042s Comparison: - simple_json: 2885.7 i/s - jb: 883.4 i/s - 3.27x (± 0.00) slower - jbuilder: 119.9 i/s - 24.06x (± 0.00) slower +simple_json(AS::json): 249.7 i/s + jb: 186.1 i/s - 1.34x slower + simple_json(oj): 159.3 i/s - 1.57x slower + jbuilder: 144.3 i/s - 1.73x slower -* Rendering 1000 partials via render_partial +* Rendering 1000 partials via render_to_string +ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [arm64-darwin24] Warming up -------------------------------------- - jb 12.000 i/100ms - jbuilder 1.000 i/100ms - simple_json 32.000 i/100ms + jb 4.000 i/100ms + jbuilder 2.000 i/100ms + simple_json(oj) 2.000 i/100ms +simple_json(AS::json) + 10.000 i/100ms Calculating ------------------------------------- - jb 124.627 (± 4.8%) i/s - 624.000 in 5.018515s - jbuilder 12.710 (± 7.9%) i/s - 64.000 in 5.073018s - simple_json 314.896 (± 3.2%) i/s - 1.600k in 5.086509s + jb 48.691 (± 2.1%) i/s (20.54 ms/i) - 244.000 in 5.013815s + jbuilder 27.930 (± 3.6%) i/s (35.80 ms/i) - 140.000 in 5.016897s + simple_json(oj) 29.083 (± 6.9%) i/s (34.38 ms/i) - 146.000 in 5.039076s +simple_json(AS::json) + 99.792 (± 7.0%) i/s (10.02 ms/i) - 500.000 in 5.037716s Comparison: - simple_json: 314.9 i/s - jb: 124.6 i/s - 2.53x (± 0.00) slower - jbuilder: 12.7 i/s - 24.78x (± 0.00) slower +simple_json(AS::json): 99.8 i/s + jb: 48.7 i/s - 2.05x slower + simple_json(oj): 29.1 i/s - 3.43x slower + jbuilder: 27.9 i/s - 3.57x slower ``` ## Migrating from Jbuilder + When migrating from Jbuilder, you can include `Migratable` in controller for migrating mode. + ``` include SimpleJson::SimpleJsonRenderable include SimpleJson::Migratable ``` In migrating mode + - Comparision will be performed for simple_json and ActionView render(Jbuilder) result. - simple_json partials not found will use Jbuilder partial as an alternative. @@ -256,7 +270,7 @@ Note that render will be performed twice, so using it in production mode is not ## Contributing -Pull requests are welcome on GitHub at https://github.com/aktsk/simple_json. +Pull requests are welcome on GitHub at . ## License diff --git a/gemfiles/rails_42.gemfile b/gemfiles/rails_42.gemfile deleted file mode 100644 index 58018ef..0000000 --- a/gemfiles/rails_42.gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -source 'https://rubygems.org' - -gemspec path: '../' - -gem 'jbuilder' -gem 'nokogiri', RUBY_VERSION < '2.1' ? '~> 1.6.0' : '>= 1.7' -gem 'rails', '~> 4.2.0' diff --git a/gemfiles/rails_52.gemfile b/gemfiles/rails_52.gemfile deleted file mode 100644 index 1b91b90..0000000 --- a/gemfiles/rails_52.gemfile +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -source 'https://rubygems.org' - -gemspec path: '../' - -gem 'jbuilder' -gem 'rails', '~> 5.2.0' diff --git a/gemfiles/rails_61.gemfile b/gemfiles/rails_7.1.gemfile similarity index 83% rename from gemfiles/rails_61.gemfile rename to gemfiles/rails_7.1.gemfile index 51bf378..021c42f 100644 --- a/gemfiles/rails_61.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -5,5 +5,5 @@ source 'https://rubygems.org' gemspec path: '../' gem 'jbuilder' -gem 'rails', '~> 6.1.0' +gem 'rails', '~> 7.1.0' gem 'selenium-webdriver' diff --git a/gemfiles/rails_60.gemfile b/gemfiles/rails_7.2.gemfile similarity index 83% rename from gemfiles/rails_60.gemfile rename to gemfiles/rails_7.2.gemfile index 1975ecc..d7339d9 100644 --- a/gemfiles/rails_60.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -5,5 +5,5 @@ source 'https://rubygems.org' gemspec path: '../' gem 'jbuilder' -gem 'rails', '~> 6.0.0' +gem 'rails', '~> 7.2.0' gem 'selenium-webdriver' diff --git a/gemfiles/rails_50.gemfile b/gemfiles/rails_8.0.gemfile similarity index 66% rename from gemfiles/rails_50.gemfile rename to gemfiles/rails_8.0.gemfile index 5b3e5a7..718c4af 100644 --- a/gemfiles/rails_50.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -5,4 +5,5 @@ source 'https://rubygems.org' gemspec path: '../' gem 'jbuilder' -gem 'rails', '~> 5.0.0' +gem 'rails', '~> 8.0.0' +gem 'selenium-webdriver' diff --git a/gemfiles/rails_51.gemfile b/gemfiles/rails_8.1.gemfile similarity index 66% rename from gemfiles/rails_51.gemfile rename to gemfiles/rails_8.1.gemfile index b4ed4c5..6f2a3da 100644 --- a/gemfiles/rails_51.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -5,4 +5,5 @@ source 'https://rubygems.org' gemspec path: '../' gem 'jbuilder' -gem 'rails', '~> 5.1.0' +gem 'rails', '~> 8.1.0' +gem 'selenium-webdriver' diff --git a/lib/simple_json.rb b/lib/simple_json.rb index f95ddbd..17f54e5 100644 --- a/lib/simple_json.rb +++ b/lib/simple_json.rb @@ -8,14 +8,13 @@ require 'simple_json/migratable' require 'simple_json/simple_json_renderer_for_migration' -require 'simple_json/json' module SimpleJson @config = { - cache_enabled: false, + template_cache_enabled: false, template_paths: ['app/views'], cache_key_prefix: 'simple_json/views', - default_json_module: Json::Oj + default_json_module: ActiveSupport::JSON } class << self diff --git a/lib/simple_json/simple_json_renderable.rb b/lib/simple_json/simple_json_renderable.rb index eaff277..91469b6 100644 --- a/lib/simple_json/simple_json_renderable.rb +++ b/lib/simple_json/simple_json_renderable.rb @@ -29,7 +29,7 @@ def render_to_body(options) # use super when any of [:body, :plain, :html] exist in options return super if self.class::RENDER_FORMATS_IN_PRIORITY.any? { |key| options.key? key } - template_name = template_name(options[:template] || action_name) + template_name = template_name(options[:template] || options[:action] || action_name) if options.key?(:json) process_options(options) @rendered_format = 'application/json; charset=utf-8' @@ -54,9 +54,7 @@ def rendered_format end def simple_renderer - @simple_renderer ||= SimpleJsonRenderer.new(self).tap do |r| - r.extend(_helpers) if respond_to?(:_helpers) - end + @simple_renderer ||= self.class.simple_json_renderer_class.new(self) end def render_json_template(template_name, **_options) @@ -68,6 +66,14 @@ def render_json_template(template_name, **_options) if SimpleJson.template_cache_enabled? && !SimpleJsonRenderer.templates_loaded? SimpleJsonRenderer.load_all_templates! end + + def self.simple_json_renderer_class + @simple_json_renderer_class ||= begin + klass = Class.new(SimpleJsonRenderer) + klass.include(_helpers) if method_defined?(:_helpers) + klass + end + end end def template_path diff --git a/lib/simple_json/simple_json_renderer.rb b/lib/simple_json/simple_json_renderer.rb index ba4e1a3..6283385 100644 --- a/lib/simple_json/simple_json_renderer.rb +++ b/lib/simple_json/simple_json_renderer.rb @@ -20,7 +20,7 @@ def load_all_templates! template_files = Rails.root.glob("#{path}/**/*.simple_json.rb") template_files.each do |file_path| template_path = file_path.relative_path_from(Rails.root.join(path)).to_path.delete_suffix('.simple_json.rb') - @renderers[template_path] = SimpleJsonTemplate.new(file_path.to_path).renderer + define_template_method(template_path, file_path.to_path) end end @templates_loaded = true @@ -29,7 +29,7 @@ def load_all_templates! def load_template(template_path) if SimpleJson.template_cache_enabled? load_all_templates! unless templates_loaded? - renderers[template_path] + render_methods[template_path] else load_template_from_file(template_path) end @@ -38,18 +38,36 @@ def load_template(template_path) def load_template_from_file(template_path) SimpleJson.template_paths.each do |path| file_path = Rails.root.join("#{path}/#{template_path}.simple_json.rb").to_path - return SimpleJsonTemplate.new(file_path).renderer if File.exist?(file_path) + + return define_template_method(template_path, file_path) if File.exist?(file_path) end nil end - def renderers - @renderers ||= {} + def define_template_method(template_path, file_path) + template = SimpleJsonTemplate.new(file_path) + code_hash = template.code.hash + + render_methods[template_path] = render_methods_cache.fetch(code_hash) do + @template_num ||= 0 + @template_num += 1 + method_name = :"template_#{@template_num}" + template.define_to_class(self, method_name) + render_methods_cache[code_hash] = method_name + end + end + + def render_methods + @render_methods ||= {} + end + + def render_methods_cache + @render_methods_cache ||= {} end def clear_renderers - @renderers = {} + @render_methods = {} @templates_loaded = false end end @@ -60,7 +78,7 @@ def initialize(controller) end def renderer(template_path) - renderers[template_path] || self.class.load_template(template_path).tap do |renderer| + renderers[template_path] || SimpleJson::SimpleJsonRenderer.load_template(template_path).tap do |renderer| renderers[template_path] = renderer end end @@ -70,10 +88,11 @@ def renderers end def render(template_name, **params) + method_name = renderer(template_name) if !params.empty? - instance_exec(**params, &renderer(template_name)) + send(method_name, **params) else - instance_exec(&renderer(template_name)) + send(method_name) end end diff --git a/lib/simple_json/simple_json_template.rb b/lib/simple_json/simple_json_template.rb index deab7b3..a375e32 100644 --- a/lib/simple_json/simple_json_template.rb +++ b/lib/simple_json/simple_json_template.rb @@ -7,8 +7,11 @@ def initialize(path) @source = File.read(path) end - def renderer - @renderer ||= eval(code, TOPLEVEL_BINDING, @path) # rubocop:disable Security/Eval + def define_to_class(klass, method_name) + method_string = to_method_string(method_name) + klass.class_eval(method_string, @path) + + method_name end def code @@ -22,5 +25,27 @@ def lambda_stringify(source) "-> { #{source} }" end + + def method_string_from_lambda(source, method_name) + pattern = %r{ + \A(?(?:\s*\#.*\n|\s+)*) + ->\s* + (?:\((?.*?)\)|(?[^\{ ]*?)) + \s*(?:\{(?.*)\}|do(?.*)end) + (?(?:\s*\#.*|\s+)*)\z + }mx + + match = source.match(pattern) + raise :parse_error unless match + + params = (match[:params_p] || match[:params_n] || "").strip + arg_part = params.empty? ? "" : "(#{params})" + + "#{match[:prefix]}def #{method_name}#{arg_part};#{match[:body]};end#{match[:suffix]}" + end + + def to_method_string(method_name) + method_string_from_lambda(code, method_name) + end end end diff --git a/lib/simple_json/version.rb b/lib/simple_json/version.rb index d1d9ec1..2809975 100644 --- a/lib/simple_json/version.rb +++ b/lib/simple_json/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SimpleJson - VERSION = '0.0.0' + VERSION = '1.0.0' end diff --git a/simple_json.gemspec b/simple_json.gemspec index 10fa57c..c339c35 100644 --- a/simple_json.gemspec +++ b/simple_json.gemspec @@ -20,15 +20,16 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] - spec.add_dependency 'oj', '~> 3.13' + spec.add_development_dependency 'oj' spec.add_development_dependency 'action_args' spec.add_development_dependency 'bundler' - spec.add_development_dependency 'byebug' - spec.add_development_dependency 'rails' + spec.add_development_dependency 'debug' + spec.add_development_dependency 'rails', '>= 7.1' spec.add_development_dependency 'rake' spec.add_development_dependency 'selenium-webdriver' spec.add_development_dependency 'test-unit-rails' + spec.add_development_dependency 'mutex_m' - spec.required_ruby_version = '>= 2.5.0' + spec.required_ruby_version = '>= 3.2.0' end diff --git a/test/dummy_app/app/controllers/api/posts_controller.rb b/test/dummy_app/app/controllers/api/posts_controller.rb index 3722553..a0f0a9b 100644 --- a/test/dummy_app/app/controllers/api/posts_controller.rb +++ b/test/dummy_app/app/controllers/api/posts_controller.rb @@ -8,6 +8,12 @@ class PostsController < ActionController::API def show(id) @post = Post.find id end + + def renamed_show(id) + @post = Post.find id + + render :show + end end end end diff --git a/test/dummy_app/app/controllers/benchmarks_controller.rb b/test/dummy_app/app/controllers/benchmarks_controller.rb index f7b0761..5646a10 100644 --- a/test/dummy_app/app/controllers/benchmarks_controller.rb +++ b/test/dummy_app/app/controllers/benchmarks_controller.rb @@ -1,23 +1,30 @@ # frozen_string_literal: true -require 'benchmark/ips' - class BenchmarksController < ApplicationController - def index(n = '100') - @comments = 1.upto(n.to_i).map { |i| Comment.new(i, nil, "comment #{i}") } - - jb = render_to_string 'index_jb' - jbuilder = render_to_string 'index_jbuilder' - simple_json = render_to_string 'index_simple_json' - raise 'jb != jbuilder' unless jb == jbuilder - raise 'simple_json != jbuilder' unless simple_json == jbuilder - - result = Benchmark.ips do |x| - x.report('jb') { render_to_string 'index_jb' } - x.report('jbuilder') { render_to_string 'index_jbuilder' } - x.report('simple_json') { render_to_string 'index_simple_json' } - x.compare! - end - render plain: result.data.to_s + before_action :prepare_comments + + def self.comments + @comments ||= 1.upto(1000).map { |i| Comment.new(i, nil, "comment #{i}") } + end + + def jb + end + + def jbuilder + end + + def simple_json_oj + SimpleJson.json_module = SimpleJson::Json::Oj + end + + def simple_json_as_json + SimpleJson.json_module = ActiveSupport::JSON + end + + private + + def prepare_comments + n = params.fetch(:n, 100).to_i + @comments = self.class.comments.take(n) end end diff --git a/test/dummy_app/app/views/benchmarks/_comment_jb.json.jb b/test/dummy_app/app/views/benchmarks/_comment_jb.json.jb index 1a0202d..db578aa 100644 --- a/test/dummy_app/app/views/benchmarks/_comment_jb.json.jb +++ b/test/dummy_app/app/views/benchmarks/_comment_jb.json.jb @@ -1,3 +1,55 @@ # frozen_string_literal: true -{ body: comment.body } +length = comment.body.length +likes = comment.id * 3 +dislikes = comment.id % 4 +rating = (likes.to_f / (dislikes.zero? ? 1 : dislikes)).round(2) +position = comment_counter + 1 + +{ + id: comment.id, + body: comment.body, + metrics: { + length: length, + readability: (length / 5.0).round(2), + engagement: { + likes: likes, + dislikes: dislikes, + rating: rating + } + }, + author: { + name: "user-#{comment.id}", + active: comment.id.odd?, + badges: (comment.id % 3).zero? ? %w[insightful] : [], + contact: { + email: "user#{comment.id}@example.com", + url: "https://example.com/users/#{comment.id}" + }, + settings: { + theme: comment.id.even? ? 'dark' : 'light', + timezone: "UTC+#{(comment.id % 5) - 2}" + } + }, + tags: ['bench', "comment-#{comment.id}", (comment.id.even? ? 'even' : nil)].compact, + flags: { + pinned: position == 1, + hidden: (comment.id % 5).zero? + }, + metadata: { + position: position, + attachments: (comment.id % 4).zero? ? [{ filename: "attachment-#{comment.id}.txt", size: comment.id * 128 }] : [], + history: [ + { version: 1, updated_by: "user-#{comment.id}", updated_at: '2024-01-01T00:00:00Z' }, + { version: 2, updated_by: "user-#{comment.id}", updated_at: '2024-01-02T00:00:00Z' } + ] + }, + links: { + self: "/comments/#{comment.id}", + post: "/posts/#{comment.post&.id || 1}" + }, + extras: { + mood: %w[happy neutral excited][comment.id % 3], + score: comment.id * position + } +} diff --git a/test/dummy_app/app/views/benchmarks/_comment_jbuilder.json.jbuilder b/test/dummy_app/app/views/benchmarks/_comment_jbuilder.json.jbuilder index 46c16ec..0066f21 100644 --- a/test/dummy_app/app/views/benchmarks/_comment_jbuilder.json.jbuilder +++ b/test/dummy_app/app/views/benchmarks/_comment_jbuilder.json.jbuilder @@ -1,3 +1,61 @@ # frozen_string_literal: true +length = comment.body.length +likes = comment.id * 3 +dislikes = comment.id % 4 +rating = (likes.to_f / (dislikes.zero? ? 1 : dislikes)).round(2) +position = comment_counter + 1 + +json.id comment.id json.body comment.body + +json.metrics do + json.length length + json.readability (length / 5.0).round(2) + json.engagement do + json.likes likes + json.dislikes dislikes + json.rating rating + end +end + +json.author do + json.name "user-#{comment.id}" + json.active comment.id.odd? + json.badges ((comment.id % 3).zero? ? %w[insightful] : []) + json.contact do + json.email "user#{comment.id}@example.com" + json.url "https://example.com/users/#{comment.id}" + end + json.settings do + json.theme(comment.id.even? ? 'dark' : 'light') + json.timezone "UTC+#{(comment.id % 5) - 2}" + end +end + +json.tags ['bench', "comment-#{comment.id}", (comment.id.even? ? 'even' : nil)].compact + +json.flags do + json.pinned position == 1 + json.hidden (comment.id % 5).zero? +end + +json.metadata do + json.position position + attachments = (comment.id % 4).zero? ? [{ filename: "attachment-#{comment.id}.txt", size: comment.id * 128 }] : [] + json.attachments attachments + json.history [ + { version: 1, updated_by: "user-#{comment.id}", updated_at: '2024-01-01T00:00:00Z' }, + { version: 2, updated_by: "user-#{comment.id}", updated_at: '2024-01-02T00:00:00Z' } + ] +end + +json.links do + json.self "/comments/#{comment.id}" + json.post "/posts/#{comment.post&.id || 1}" +end + +json.extras do + json.mood %w[happy neutral excited][comment.id % 3] + json.score comment.id * position +end diff --git a/test/dummy_app/app/views/benchmarks/_comment_simple_json.simple_json.rb b/test/dummy_app/app/views/benchmarks/_comment_simple_json.simple_json.rb index 09565b8..0d249a7 100644 --- a/test/dummy_app/app/views/benchmarks/_comment_simple_json.simple_json.rb +++ b/test/dummy_app/app/views/benchmarks/_comment_simple_json.simple_json.rb @@ -1,7 +1,56 @@ # frozen_string_literal: true -->(comment:) { +->(comment:, position:) { + length = comment.body.length + likes = comment.id * 3 + dislikes = comment.id % 4 + rating = (likes.to_f / (dislikes.zero? ? 1 : dislikes)).round(2) + { - body: comment.body + id: comment.id, + body: comment.body, + metrics: { + length: length, + readability: (length / 5.0).round(2), + engagement: { + likes: likes, + dislikes: dislikes, + rating: rating + } + }, + author: { + name: "user-#{comment.id}", + active: comment.id.odd?, + badges: (comment.id % 3).zero? ? %w[insightful] : [], + contact: { + email: "user#{comment.id}@example.com", + url: "https://example.com/users/#{comment.id}" + }, + settings: { + theme: comment.id.even? ? 'dark' : 'light', + timezone: "UTC+#{(comment.id % 5) - 2}" + } + }, + tags: ['bench', "comment-#{comment.id}", (comment.id.even? ? 'even' : nil)].compact, + flags: { + pinned: position == 1, + hidden: (comment.id % 5).zero? + }, + metadata: { + position: position, + attachments: (comment.id % 4).zero? ? [{ filename: "attachment-#{comment.id}.txt", size: comment.id * 128 }] : [], + history: [ + { version: 1, updated_by: "user-#{comment.id}", updated_at: '2024-01-01T00:00:00Z' }, + { version: 2, updated_by: "user-#{comment.id}", updated_at: '2024-01-02T00:00:00Z' } + ] + }, + links: { + self: "/comments/#{comment.id}", + post: "/posts/#{comment.post&.id || 1}" + }, + extras: { + mood: %w[happy neutral excited][comment.id % 3], + score: comment.id * position + } } } diff --git a/test/dummy_app/app/views/benchmarks/index_jb.json.jb b/test/dummy_app/app/views/benchmarks/index_jb.json.jb deleted file mode 100644 index 592d814..0000000 --- a/test/dummy_app/app/views/benchmarks/index_jb.json.jb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -{ comments: render(partial: 'comment_jb', collection: @comments, as: 'comment') } diff --git a/test/dummy_app/app/views/benchmarks/index_jbuilder.json.jbuilder b/test/dummy_app/app/views/benchmarks/index_jbuilder.json.jbuilder deleted file mode 100644 index 1dca7dc..0000000 --- a/test/dummy_app/app/views/benchmarks/index_jbuilder.json.jbuilder +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -json.comments do - json.partial! 'comment_jbuilder', collection: @comments, as: 'comment' -end diff --git a/test/dummy_app/app/views/benchmarks/index_simple_json.simple_json.rb b/test/dummy_app/app/views/benchmarks/index_simple_json.simple_json.rb deleted file mode 100644 index e37cf1e..0000000 --- a/test/dummy_app/app/views/benchmarks/index_simple_json.simple_json.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -{ - comments: @comments.map { |comment| partial!('benchmarks/_comment_simple_json', comment: comment) } -} diff --git a/test/dummy_app/app/views/benchmarks/jb.json.jb b/test/dummy_app/app/views/benchmarks/jb.json.jb new file mode 100644 index 0000000..eb71ef6 --- /dev/null +++ b/test/dummy_app/app/views/benchmarks/jb.json.jb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +lengths = @comments.map { |comment| comment.body.length } +avg_length = lengths.empty? ? 0.0 : (lengths.sum.to_f / lengths.size).round(2) + +{ + meta: { + total_comments: @comments.size, + longest_comment_length: lengths.max || 0, + average_comment_length: avg_length, + tags: %w[bench simple json], + flags: { + has_many: @comments.size > 500, + empty: @comments.empty? + } + }, + comments: render(partial: 'comment_jb', collection: @comments, as: 'comment') +} diff --git a/test/dummy_app/app/views/benchmarks/jbuilder.json.jbuilder b/test/dummy_app/app/views/benchmarks/jbuilder.json.jbuilder new file mode 100644 index 0000000..8a28de6 --- /dev/null +++ b/test/dummy_app/app/views/benchmarks/jbuilder.json.jbuilder @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +lengths = @comments.map { |comment| comment.body.length } +avg_length = lengths.empty? ? 0.0 : (lengths.sum.to_f / lengths.size).round(2) + +json.meta do + json.total_comments @comments.size + json.longest_comment_length lengths.max || 0 + json.average_comment_length avg_length + json.tags %w[bench simple json] + json.flags do + json.has_many @comments.size > 500 + json.empty @comments.empty? + end +end + +json.comments do + json.partial! 'comment_jbuilder', collection: @comments, as: 'comment' +end diff --git a/test/dummy_app/app/views/benchmarks/simple_json_as_json.simple_json.rb b/test/dummy_app/app/views/benchmarks/simple_json_as_json.simple_json.rb new file mode 100644 index 0000000..b5dc72e --- /dev/null +++ b/test/dummy_app/app/views/benchmarks/simple_json_as_json.simple_json.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +lengths = @comments.map { |comment| comment.body.length } +avg_length = lengths.empty? ? 0.0 : (lengths.sum.to_f / lengths.size).round(2) + +{ + meta: { + total_comments: @comments.size, + longest_comment_length: lengths.max || 0, + average_comment_length: avg_length, + tags: %w[bench simple json], + flags: { + has_many: @comments.size > 500, + empty: @comments.empty? + } + }, + comments: @comments.each_with_index.map do |comment, index| + partial!('benchmarks/_comment_simple_json', comment: comment, position: index + 1) + end +} diff --git a/test/dummy_app/app/views/benchmarks/simple_json_oj.simple_json.rb b/test/dummy_app/app/views/benchmarks/simple_json_oj.simple_json.rb new file mode 100644 index 0000000..b5dc72e --- /dev/null +++ b/test/dummy_app/app/views/benchmarks/simple_json_oj.simple_json.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +lengths = @comments.map { |comment| comment.body.length } +avg_length = lengths.empty? ? 0.0 : (lengths.sum.to_f / lengths.size).round(2) + +{ + meta: { + total_comments: @comments.size, + longest_comment_length: lengths.max || 0, + average_comment_length: avg_length, + tags: %w[bench simple json], + flags: { + has_many: @comments.size > 500, + empty: @comments.empty? + } + }, + comments: @comments.each_with_index.map do |comment, index| + partial!('benchmarks/_comment_simple_json', comment: comment, position: index + 1) + end +} diff --git a/test/dummy_app/bin/bench.rb b/test/dummy_app/bin/bench.rb index f99419b..a5cd789 100644 --- a/test/dummy_app/bin/bench.rb +++ b/test/dummy_app/bin/bench.rb @@ -1,14 +1,80 @@ # frozen_string_literal: true require 'action_dispatch/testing/integration' +require 'benchmark/ips' +require 'json' -puts '* Rendering 10 partials via render_partial' -ActionDispatch::Integration::Session.new(Rails.application).get '/benchmarks.json?n=10' +SCENARIOS = [ + { + label: 'jb', + path: '/benchmarks/jb.json', + decoder: ->(body) { JSON.parse(body) } + }, + { + label: 'jbuilder', + path: '/benchmarks/jbuilder.json', + decoder: ->(body) { JSON.parse(body) } + }, + { + label: 'simple_json(oj)', + path: '/benchmarks/simple_json_oj.json', + decoder: ->(body) { JSON.parse(body) } + }, + { + label: 'simple_json(AS::json)', + path: '/benchmarks/simple_json_as_json.json', + decoder: ->(body) { JSON.parse(body) } + } +].freeze -puts -puts '* Rendering 100 partials via render_partial' -ActionDispatch::Integration::Session.new(Rails.application).get '/benchmarks.json?n=100' +SimpleJson.enable_template_cache -puts -puts '* Rendering 1000 partials via render_partial' -ActionDispatch::Integration::Session.new(Rails.application).get '/benchmarks.json?n=1000' +def perform_request(path, n:) + session = ActionDispatch::Integration::Session.new(Rails.application) + session.get(path, params: { n: n }) + raise "Unexpected status #{session.response.status} for #{path}" unless session.response.successful? + session.response +end + +def verify_payloads(n) + baseline = SCENARIOS.first + expected_response = perform_request(baseline[:path], n: n) + expected_payload = baseline[:decoder].call(expected_response.body) + + SCENARIOS[1..].each do |scenario| + response = perform_request(scenario[:path], n: n) + if scenario[:media_type] && response.media_type != scenario[:media_type] + raise "#{scenario[:label]} content type mismatch: #{response.media_type}" + end + + actual_payload = scenario[:decoder].call(response.body) + raise "#{scenario[:label]} payload mismatch" unless actual_payload == expected_payload + end +end + +def benchmark_requests(n) + puts + puts "* Rendering #{n} partials via render_to_string" + + verify_payloads(n) + + Benchmark.ips do |x| + SCENARIOS.each do |scenario| + x.report(scenario[:label]) do + perform_request(scenario[:path], n: n) + end + end + x.compare! + end +end + +puts 'SimpleJson Benchmark' +puts "ruby: #{RUBY_VERSION}" +puts "rails: #{Rails.version}" +puts "json: #{JSON::VERSION}" +puts "oj: #{Oj::VERSION}" +puts "----------------------" + +[10, 100, 1000].each do |n| + benchmark_requests(n) +end diff --git a/test/dummy_app/config/environments/test.rb b/test/dummy_app/config/environments/test.rb index b1d2da7..d8da507 100644 --- a/test/dummy_app/config/environments/test.rb +++ b/test/dummy_app/config/environments/test.rb @@ -19,7 +19,7 @@ config.action_controller.perform_caching = false # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = false + config.action_dispatch.show_exceptions = :none # Disable request forgery protection in test environment. config.action_controller.allow_forgery_protection = false diff --git a/lib/simple_json/json.rb b/test/dummy_app/config/initializers/simple_json/json.rb similarity index 100% rename from lib/simple_json/json.rb rename to test/dummy_app/config/initializers/simple_json/json.rb diff --git a/test/dummy_app/config/routes.rb b/test/dummy_app/config/routes.rb index f95f929..f32cd37 100644 --- a/test/dummy_app/config/routes.rb +++ b/test/dummy_app/config/routes.rb @@ -15,11 +15,19 @@ resources :posts_with_cache, only: :show resources :posts_with_multiple_view_paths, only: :show - resources :benchmarks, only: :index + resources :benchmarks, only: [] do + collection do + get :jb, defaults: { format: :json } + get :jbuilder, defaults: { format: :json } + get :simple_json_oj, defaults: { format: :json } + get :simple_json_as_json, defaults: { format: :json } + end + end if Rails::VERSION::MAJOR >= 5 namespace :api do resources :posts, only: :show + get '/renamed_posts/:id', to: 'posts#renamed_show' end end # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html diff --git a/test/features/action_controller_api_test.rb b/test/features/action_controller_api_test.rb index 76871f0..cb72c13 100644 --- a/test/features/action_controller_api_test.rb +++ b/test/features/action_controller_api_test.rb @@ -12,6 +12,14 @@ class ActionControllerAPITest < ActionDispatch::IntegrationTest assert_equal json, { 'title' => 'post 1' } end + + test 'The renamed template correctly renders a JSON' do + get '/api/renamed_posts/1.json' + + json = response.parsed_body + + assert_equal json, { 'title' => 'post 1' } + end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3fea8a6..3fdfcbd 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,5 +8,5 @@ Bundler.require require 'action_args' -require 'byebug' +require 'debug' require 'test/unit/rails/test_help' diff --git a/test/unit/simple_json_template_test.rb b/test/unit/simple_json_template_test.rb new file mode 100644 index 0000000..6d6b837 --- /dev/null +++ b/test/unit/simple_json_template_test.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SimpleJsonTemplateTest < Test::Unit::TestCase + # -> { body } form + test 'converts simple lambda with braces' do + source = '-> { "hello" }' + result = call_method(source, :foo) + + assert_equal 'def foo; "hello" ;end', result + end + + # ->(params) { body } form (parenthesized parameters) + test 'converts lambda with parenthesized parameters' do + source = '->(a, b) { a + b }' + result = call_method(source, :add) + + assert_equal 'def add(a, b); a + b ;end', result + end + + # ->params { body } form (no parentheses, no space) + test 'converts lambda with parameter without parentheses' do + source = '->a { a * 2 }' + result = call_method(source, :double) + + assert_equal 'def double(a); a * 2 ;end', result + end + + # -> do body end form + test 'converts lambda with do-end' do + source = '-> do "hello" end' + result = call_method(source, :foo) + + assert_equal 'def foo; "hello" ;end', result + end + + # ->(params) do body end form + test 'converts lambda with parameters and do-end' do + source = '->(x) do x * 2 end' + result = call_method(source, :double) + + assert_equal 'def double(x); x * 2 ;end', result + end + + # Multiline body + test 'converts multiline lambda body' do + source = <<~RUBY + -> { + x = 1 + y = 2 + x + y + } + RUBY + result = call_method(source, :calc) + + assert_match(/def calc;/, result) + assert_match(/x = 1/, result) + assert_match(/y = 2/, result) + assert_match(/x \+ y/, result) + assert_match(/;end/, result) + end + + # Preserve prefix comments + test 'preserves prefix comments' do + source = <<~RUBY + # This is a comment + -> { "hello" } + RUBY + result = call_method(source, :foo) + + assert_match(/\A# This is a comment\n/, result) + assert_match(/def foo;/, result) + end + + # Preserve suffix comments + test 'preserves suffix comments' do + source = <<~RUBY + -> { "hello" } + # trailing comment + RUBY + result = call_method(source, :foo) + + assert_match(/;end\n# trailing comment/, result) + end + + # Empty parameter list + test 'converts lambda with empty parentheses' do + source = '->() { "hello" }' + result = call_method(source, :foo) + + assert_equal 'def foo; "hello" ;end', result + end + + # Space before brace with no parameters + test 'converts lambda with space before brace' do + source = '-> { "hello" }' + result = call_method(source, :foo) + + assert_equal 'def foo; "hello" ;end', result + end + + # Multiline do-end form + test 'converts multiline do-end lambda' do + source = <<~RUBY + -> do + result = [] + result << 1 + result + end + RUBY + result = call_method(source, :build) + + assert_match(/def build;/, result) + assert_match(/result = \[\]/, result) + assert_match(/;end/, result) + end + + # --- Parameter types --- + + # Default argument + test 'converts lambda with default parameter' do + source = '->(a, b = 1) { a + b }' + result = call_method(source, :add) + + assert_equal 'def add(a, b = 1); a + b ;end', result + end + + # Keyword arguments + test 'converts lambda with keyword parameters' do + source = '->(a:, b: 1) { a + b }' + result = call_method(source, :add) + + assert_equal 'def add(a:, b: 1); a + b ;end', result + end + + # Variadic arguments + test 'converts lambda with splat parameter' do + source = '->(*args) { args }' + result = call_method(source, :collect) + + assert_equal 'def collect(*args); args ;end', result + end + + # Block parameter + test 'converts lambda with block parameter' do + source = '->(&block) { block.call }' + result = call_method(source, :execute) + + assert_equal 'def execute(&block); block.call ;end', result + end + + # Double splat + test 'converts lambda with double splat parameter' do + source = '->(**opts) { opts }' + result = call_method(source, :options) + + assert_equal 'def options(**opts); opts ;end', result + end + + # --- Body edge cases --- + + # Nested braces + test 'converts lambda with nested braces in body' do + source = '-> { { key: value } }' + result = call_method(source, :hash) + + assert_equal 'def hash; { key: value } ;end', result + end + + # Empty body (braces) + test 'converts lambda with empty body' do + source = '-> { }' + result = call_method(source, :noop) + + assert_equal 'def noop; ;end', result + end + + # Empty body (do-end) + test 'converts do-end lambda with empty body' do + source = '-> do end' + result = call_method(source, :noop) + + assert_equal 'def noop; ;end', result + end + + # do-end body contains end keyword + test 'converts do-end lambda with nested end keyword' do + source = '-> do if x then y end end' + result = call_method(source, :conditional) + + assert_equal 'def conditional; if x then y end ;end', result + end + + # Body string contains end + test 'converts lambda with end string in body' do + source = '-> { "end of string" }' + result = call_method(source, :message) + + assert_equal 'def message; "end of string" ;end', result + end + + # --- Prefix edge cases --- + + # Multiple prefix comments + test 'preserves multiple prefix comments' do + source = <<~RUBY + # comment 1 + # comment 2 + -> { 1 } + RUBY + result = call_method(source, :foo) + + assert_match(/\A# comment 1\n# comment 2\n/, result) + assert_match(/def foo;/, result) + end + + # Blank line prefix + test 'preserves blank line prefix' do + source = "\n\n-> { 1 }" + result = call_method(source, :foo) + + assert_match(/\A\n\ndef foo;/, result) + end + + # --- Error cases --- + + # Non-lambda input + test 'raises error for non-lambda input' do + source = 'def foo; end' + + assert_raise(TypeError) do + call_method(source, :foo) + end + end + + # Proc.new input + test 'raises error for Proc.new input' do + source = 'Proc.new { 1 }' + + assert_raise(TypeError) do + call_method(source, :foo) + end + end + + private + + def call_method(source, method_name) + SimpleJson::SimpleJsonTemplate.allocate.send(:method_string_from_lambda, source, method_name) + end +end