Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ 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
-> {
Expand Down
35 changes: 27 additions & 8 deletions lib/simple_json/simple_json_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
29 changes: 27 additions & 2 deletions lib/simple_json/simple_json_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,5 +25,27 @@ def lambda_stringify(source)

"-> { #{source} }"
end

def method_string_from_lambda(source, method_name)
pattern = %r{
\A(?<prefix>(?:\s*\#.*\n|\s+)*)
->\s*
(?:\((?<params_p>.*?)\)|(?<params_n>[^\{ ]*?))
\s*(?:\{(?<body>.*)\}|do(?<body>.*)end)
(?<suffix>(?:\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
251 changes: 251 additions & 0 deletions test/unit/simple_json_template_test.rb
Original file line number Diff line number Diff line change
@@ -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