ruby/test/erb/test_erb.rb
Aaron Patterson 8ac8225c50 Inline Class#new.
This commit inlines instructions for Class#new.  To make this work, we
added a new YARV instructions, `opt_new`.  `opt_new` checks whether or
not the `new` method is the default allocator method.  If it is, it
allocates the object, and pushes the instance on the stack.  If not, the
instruction jumps to the "slow path" method call instructions.

Old instructions:

```
> ruby --dump=insns -e'Object.new'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)>
0000 opt_getconstant_path                   <ic:0 Object>             (   1)[Li]
0002 opt_send_without_block                 <calldata!mid:new, argc:0, ARGS_SIMPLE>
0004 leave
```

New instructions:

```
> ./miniruby --dump=insns -e'Object.new'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)>
0000 opt_getconstant_path                   <ic:0 Object>             (   1)[Li]
0002 putnil
0003 swap
0004 opt_new                                <calldata!mid:new, argc:0, ARGS_SIMPLE>, 11
0007 opt_send_without_block                 <calldata!mid:initialize, argc:0, FCALL|ARGS_SIMPLE>
0009 jump                                   14
0011 opt_send_without_block                 <calldata!mid:new, argc:0, ARGS_SIMPLE>
0013 swap
0014 pop
0015 leave
```

This commit speeds up basic object allocation (`Foo.new`) by 60%, but
classes that take keyword parameters see an even bigger benefit because
no hash is allocated when instantiating the object (3x to 6x faster).

Here is an example that uses `Hash.new(capacity: 0)`:

```
> hyperfine "ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end'" "./ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end'"
Benchmark 1: ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end'
  Time (mean ± σ):      1.082 s ±  0.004 s    [User: 1.074 s, System: 0.008 s]
  Range (min … max):    1.076 s …  1.088 s    10 runs

Benchmark 2: ./ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end'
  Time (mean ± σ):     627.9 ms ±   3.5 ms    [User: 622.7 ms, System: 4.8 ms]
  Range (min … max):   622.7 ms … 633.2 ms    10 runs

Summary
  ./ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end' ran
    1.72 ± 0.01 times faster than ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end'
```

This commit changes the backtrace for `initialize`:

```
aaron@tc ~/g/ruby (inline-new)> cat test.rb
class Foo
  def initialize
    puts caller
  end
end

def hello
  Foo.new
end

hello
aaron@tc ~/g/ruby (inline-new)> ruby -v test.rb
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]
test.rb:8:in 'Class#new'
test.rb:8:in 'Object#hello'
test.rb:11:in '<main>'
aaron@tc ~/g/ruby (inline-new)> ./miniruby -v test.rb
ruby 3.5.0dev (2025-03-28T23:59:40Z inline-new c4157884e4) +PRISM [arm64-darwin24]
test.rb:8:in 'Object#hello'
test.rb:11:in '<main>'
```

It also increases memory usage for calls to `new` by 122 bytes:

```
aaron@tc ~/g/ruby (inline-new)> cat test.rb
require "objspace"

class Foo
  def initialize
    puts caller
  end
end

def hello
  Foo.new
end

puts ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(method(:hello)))
aaron@tc ~/g/ruby (inline-new)> make runruby
RUBY_ON_BUG='gdb -x ./.gdbinit -p' ./miniruby -I./lib -I. -I.ext/common  ./tool/runruby.rb --extout=.ext  -- --disable-gems  ./test.rb
656
aaron@tc ~/g/ruby (inline-new)> ruby -v test.rb
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]
544
```

Thanks to @ko1 for coming up with this idea!

Co-Authored-By: John Hawthorn <john@hawthorn.email>
2025-04-25 13:46:05 -07:00

747 lines
17 KiB
Ruby

# -*- coding: us-ascii -*-
# frozen_string_literal: false
require 'test/unit'
require 'erb'
require 'stringio'
class TestERB < Test::Unit::TestCase
class MyError < RuntimeError ; end
def test_without_filename
erb = ERB.new("<% raise ::TestERB::MyError %>")
e = assert_raise(MyError) {
erb.result
}
assert_match(/\A\(erb\):1\b/, e.backtrace[0])
end
def test_with_filename
erb = ERB.new("<% raise ::TestERB::MyError %>")
erb.filename = "test filename"
e = assert_raise(MyError) {
erb.result
}
assert_match(/\Atest filename:1\b/, e.backtrace[0])
end
# [deprecated] This will be removed later
def test_without_filename_with_safe_level
erb = EnvUtil.suppress_warning do
ERB.new("<% raise ::TestERB::MyError %>", 1)
end
e = assert_raise(MyError) {
erb.result
}
assert_match(/\A\(erb\):1\b/, e.backtrace[0])
end
# [deprecated] This will be removed later
def test_with_filename_and_safe_level
erb = EnvUtil.suppress_warning do
ERB.new("<% raise ::TestERB::MyError %>", 1)
end
erb.filename = "test filename"
e = assert_raise(MyError) {
erb.result
}
assert_match(/\Atest filename:1\b/, e.backtrace[0])
end
def test_with_filename_lineno
erb = ERB.new("<% raise ::TestERB::MyError %>")
erb.filename = "test filename"
erb.lineno = 100
e = assert_raise(MyError) {
erb.result
}
assert_match(/\Atest filename:101\b/, e.backtrace[0])
end
def test_with_location
erb = ERB.new("<% raise ::TestERB::MyError %>")
erb.location = ["test filename", 200]
e = assert_raise(MyError) {
erb.result
}
assert_match(/\Atest filename:201\b/, e.backtrace[0])
end
def test_html_escape
assert_equal(" !&quot;\#$%&amp;&#39;()*+,-./0123456789:;&lt;=&gt;?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
ERB::Util.html_escape(" !\"\#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"))
assert_equal("", ERB::Util.html_escape(""))
assert_equal("abc", ERB::Util.html_escape("abc"))
assert_equal("&lt;&lt;", ERB::Util.html_escape("<\<"))
assert_equal("&#39;&amp;&quot;&gt;&lt;", ERB::Util.html_escape("'&\"><"))
assert_equal("", ERB::Util.html_escape(nil))
assert_equal("123", ERB::Util.html_escape(123))
end
def test_html_escape_to_s
object = Object.new
def object.to_s
"object"
end
assert_equal("object", ERB::Util.html_escape(object))
end
def test_html_escape_extension
assert_nil(ERB::Util.method(:html_escape).source_location)
end if RUBY_ENGINE == 'ruby'
def test_concurrent_default_binding
# This test randomly fails with JRuby -- NameError: undefined local variable or method `template2'
pend if RUBY_ENGINE == 'jruby'
template1 = 'one <%= ERB.new(template2).result %>'
eval 'template2 = "two"', TOPLEVEL_BINDING
bug7046 = '[ruby-core:47638]'
assert_equal("one two", ERB.new(template1).result, bug7046)
end
end
class TestERBCore < Test::Unit::TestCase
def setup
@erb = ERB
end
def test_version
assert_equal(String, @erb.version.class)
end
def test_core
# [deprecated] Fix initializer later
EnvUtil.suppress_warning do
_test_core(nil)
_test_core(0)
_test_core(1)
end
end
def _test_core(safe)
erb = @erb.new("hello")
assert_equal("hello", erb.result)
erb = @erb.new("hello", safe, 0)
assert_equal("hello", erb.result)
erb = @erb.new("hello", safe, 1)
assert_equal("hello", erb.result)
erb = @erb.new("hello", safe, 2)
assert_equal("hello", erb.result)
src = <<EOS
%% hi
= hello
<% 3.times do |n| %>
% n=0
* <%= n %>
<% end %>
EOS
ans = <<EOS
%% hi
= hello
% n=0
* 0
% n=0
* 1
% n=0
* 2
EOS
erb = @erb.new(src)
assert_equal(ans, erb.result)
erb = @erb.new(src, safe, 0)
assert_equal(ans, erb.result)
erb = @erb.new(src, safe, '')
assert_equal(ans, erb.result)
ans = <<EOS
%% hi
= hello
% n=0
* 0% n=0
* 1% n=0
* 2
EOS
erb = @erb.new(src, safe, 1)
assert_equal(ans.chomp, erb.result)
erb = @erb.new(src, safe, '>')
assert_equal(ans.chomp, erb.result)
ans = <<EOS
%% hi
= hello
% n=0
* 0
% n=0
* 1
% n=0
* 2
EOS
erb = @erb.new(src, safe, 2)
assert_equal(ans, erb.result)
erb = @erb.new(src, safe, '<>')
assert_equal(ans, erb.result)
ans = <<EOS
% hi
= hello
* 0
* 0
* 0
EOS
erb = @erb.new(src, safe, '%')
assert_equal(ans, erb.result)
ans = <<EOS
% hi
= hello
* 0* 0* 0
EOS
erb = @erb.new(src, safe, '%>')
assert_equal(ans.chomp, erb.result)
ans = <<EOS
% hi
= hello
* 0
* 0
* 0
EOS
erb = @erb.new(src, safe, '%<>')
assert_equal(ans, erb.result)
end
def test_trim_line1_with_carriage_return
erb = @erb.new("<% 3.times do %>\r\nline\r\n<% end %>\r\n", trim_mode: '>')
assert_equal("line\r\n" * 3, erb.result)
erb = @erb.new("<% 3.times do %>\r\nline\r\n<% end %>\r\n", trim_mode: '%>')
assert_equal("line\r\n" * 3, erb.result)
end
def test_trim_line2_with_carriage_return
erb = @erb.new("<% 3.times do %>\r\nline\r\n<% end %>\r\n", trim_mode: '<>')
assert_equal("line\r\n" * 3, erb.result)
erb = @erb.new("<% 3.times do %>\r\nline\r\n<% end %>\r\n", trim_mode: '%<>')
assert_equal("line\r\n" * 3, erb.result)
end
def test_explicit_trim_line_with_carriage_return
erb = @erb.new("<%- 3.times do -%>\r\nline\r\n<%- end -%>\r\n", trim_mode: '-')
assert_equal("line\r\n" * 3, erb.result)
erb = @erb.new("<%- 3.times do -%>\r\nline\r\n<%- end -%>\r\n", trim_mode: '%-')
assert_equal("line\r\n" * 3, erb.result)
end
def test_safe_level_warning
assert_warning(/#{__FILE__}:#{__LINE__ + 1}/) do
@erb.new("", 1)
end
end
def test_invalid_trim_mode
pend if RUBY_ENGINE == 'truffleruby'
assert_warning(/#{__FILE__}:#{__LINE__ + 1}/) do
@erb.new("", trim_mode: 'abc-def')
end
assert_warning(/Invalid ERB trim mode/) do
@erb.new("", trim_mode: 'abc-def')
end
assert_warning(/Invalid ERB trim mode/) do
@erb.new("", trim_mode: '%<')
end
assert_warning(/Invalid ERB trim mode/) do
@erb.new("", trim_mode: '%<>-')
end
assert_warning(/Invalid ERB trim mode/) do
@erb.new("", trim_mode: 3)
end
end
def test_run
out = StringIO.new
orig, $stdout = $stdout, out
num = 3
@erb.new('<%= num * 3 %>').run(binding)
$stdout = orig
out.rewind
assert_equal('9', out.read)
return unless num # to remove warning
end
class Foo; end
def test_def_class
erb = @erb.new('hello')
cls = erb.def_class
assert_equal(Object, cls.superclass)
assert_respond_to(cls.new, 'result')
cls = erb.def_class(Foo)
assert_equal(Foo, cls.superclass)
assert_respond_to(cls.new, 'result')
cls = erb.def_class(Object, 'erb')
assert_equal(Object, cls.superclass)
assert_respond_to(cls.new, 'erb')
end
def test_percent
src = <<EOS
%n = 1
<%= n%>
EOS
assert_equal("1\n", ERB.new(src, trim_mode: '%').result(binding))
src = <<EOS
<%
%>
EOS
ans = "\n"
assert_equal(ans, ERB.new(src, trim_mode: '%').result(binding))
src = "<%\n%>"
# ans = "\n"
ans = ""
assert_equal(ans, ERB.new(src, trim_mode: '%').result(binding))
src = <<EOS
<%
n = 1
%><%= n%>
EOS
assert_equal("1\n", ERB.new(src, trim_mode: '%').result(binding))
src = <<EOS
%n = 1
%% <% n = 2
n.times do |i|%>
%% %%><%%<%= i%><%
end%>
%%%
EOS
ans = <<EOS
%\s
% %%><%0
% %%><%1
%%
EOS
assert_equal(ans, ERB.new(src, trim_mode: '%').result(binding))
end
def test_def_erb_method
klass = Class.new
klass.module_eval do
extend ERB::DefMethod
fname = File.join(File.dirname(File.expand_path(__FILE__)), 'hello.erb')
def_erb_method('hello', fname)
end
assert_respond_to(klass.new, 'hello')
assert_not_respond_to(klass.new, 'hello_world')
erb = @erb.new('hello, world')
klass.module_eval do
def_erb_method('hello_world', erb)
end
assert_respond_to(klass.new, 'hello_world')
end
def test_def_method_without_filename
klass = Class.new
erb = ERB.new("<% raise ::TestERB::MyError %>")
erb.filename = "test filename"
assert_not_respond_to(klass.new, 'my_error')
erb.def_method(klass, 'my_error')
e = assert_raise(::TestERB::MyError) {
klass.new.my_error
}
assert_match(/\A\(ERB\):1\b/, e.backtrace[0])
end
def test_def_method_with_fname
klass = Class.new
erb = ERB.new("<% raise ::TestERB::MyError %>")
erb.filename = "test filename"
assert_not_respond_to(klass.new, 'my_error')
erb.def_method(klass, 'my_error', 'test fname')
e = assert_raise(::TestERB::MyError) {
klass.new.my_error
}
assert_match(/\Atest fname:1\b/, e.backtrace[0])
end
def test_def_module
klass = Class.new
klass.include ERB.new('<%= val %>').def_module('render(val)')
assert_equal('1', klass.new.render(1))
end
def test_escape
src = <<EOS
1.<%% : <%="<%%"%>
2.%%> : <%="%%>"%>
3.
% x = "foo"
<%=x%>
4.
%% print "foo"
5.
%% <%="foo"%>
6.<%="
% print 'foo'
"%>
7.<%="
%% print 'foo'
"%>
EOS
ans = <<EOS
1.<% : <%%
2.%%> : %>
3.
foo
4.
% print "foo"
5.
% foo
6.
% print 'foo'
7.
%% print 'foo'
EOS
assert_equal(ans, ERB.new(src, trim_mode: '%').result)
end
def test_keep_lineno
src = <<EOS
Hello,\s
% x = "World"
<%= x%>
% raise("lineno")
EOS
erb = ERB.new(src, trim_mode: '%')
e = assert_raise(RuntimeError) {
erb.result
}
assert_match(/\A\(erb\):4\b/, e.backtrace[0].to_s)
src = <<EOS
%>
Hello,\s
<% x = "World%%>
"%>
<%= x%>
EOS
ans = <<EOS
%>Hello,\s
World%>
EOS
assert_equal(ans, ERB.new(src, trim_mode: '>').result)
ans = <<EOS
%>
Hello,\s
World%>
EOS
assert_equal(ans, ERB.new(src, trim_mode: '<>').result)
ans = <<EOS
%>
Hello,\s
World%>
EOS
assert_equal(ans, ERB.new(src).result)
src = <<EOS
Hello,\s
<% x = "World%%>
"%>
<%= x%>
<% raise("lineno") %>
EOS
erb = ERB.new(src)
e = assert_raise(RuntimeError) {
erb.result
}
assert_match(/\A\(erb\):5\b/, e.backtrace[0].to_s)
erb = ERB.new(src, trim_mode: '>')
e = assert_raise(RuntimeError) {
erb.result
}
assert_match(/\A\(erb\):5\b/, e.backtrace[0].to_s)
erb = ERB.new(src, trim_mode: '<>')
e = assert_raise(RuntimeError) {
erb.result
}
assert_match(/\A\(erb\):5\b/, e.backtrace[0].to_s)
src = <<EOS
% y = 'Hello'
<%- x = "World%%>
"-%>
<%= x %><%- x = nil -%>\s
<% raise("lineno") %>
EOS
erb = ERB.new(src, trim_mode: '-')
e = assert_raise(RuntimeError) {
erb.result
}
assert_match(/\A\(erb\):5\b/, e.backtrace[0].to_s)
erb = ERB.new(src, trim_mode: '%-')
e = assert_raise(RuntimeError) {
erb.result
}
assert_match(/\A\(erb\):5\b/, e.backtrace[0].to_s)
end
def test_explicit
src = <<EOS
<% x = %w(hello world) -%>
NotSkip <%- y = x -%> NotSkip
<% x.each do |w| -%>
<%- up = w.upcase -%>
* <%= up %>
<% end -%>
<%- z = nil -%> NotSkip <%- z = x %>
<%- z.each do |w| -%>
<%- down = w.downcase -%>
* <%= down %>
<%- up = w.upcase -%>
* <%= up %>
<%- end -%>
KeepNewLine <%- z = nil -%>\s
EOS
ans = <<EOS
NotSkip NotSkip
* HELLO
* WORLD
NotSkip\s
* hello
* HELLO
* world
* WORLD
KeepNewLine \s
EOS
assert_equal(ans, ERB.new(src, trim_mode: '-').result)
assert_equal(ans, ERB.new(src, trim_mode: '-%').result)
end
def test_url_encode
assert_equal("Programming%20Ruby%3A%20%20The%20Pragmatic%20Programmer%27s%20Guide",
ERB::Util.url_encode("Programming Ruby: The Pragmatic Programmer's Guide"))
assert_equal("%A5%B5%A5%F3%A5%D7%A5%EB",
ERB::Util.url_encode("\xA5\xB5\xA5\xF3\xA5\xD7\xA5\xEB".force_encoding("EUC-JP")))
assert_equal("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~",
ERB::Util.url_encode("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"),
"should not escape any unreserved characters, as per RFC3986 Section 2.3")
end
def test_percent_after_etag
assert_equal("1%", @erb.new("<%= 1 %>%", trim_mode: "%").result)
end
def test_token_extension
extended_erb = Class.new(ERB)
extended_erb.module_eval do
def make_compiler(trim_mode)
compiler = Class.new(ERB::Compiler)
compiler.module_eval do
def compile_stag(stag, out, scanner)
case stag
when '<%=='
scanner.stag = stag
add_put_cmd(out, content) if content.size > 0
self.content = ''
else
super
end
end
def compile_content(stag, out)
case stag
when '<%=='
out.push("#{@insert_cmd}(::ERB::Util.html_escape(#{content}))")
else
super
end
end
def make_scanner(src)
scanner = Class.new(ERB::Compiler::SimpleScanner)
scanner.module_eval do
def stags
['<%=='] + super
end
end
scanner.new(src, @trim_mode, @percent)
end
end
compiler.new(trim_mode)
end
end
src = <<~EOS
<% tag = '<>' \%>
<\%= tag \%>
<\%== tag \%>
EOS
ans = <<~EOS
<>
&lt;&gt;
EOS
assert_equal(ans, extended_erb.new(src).result)
end
def test_frozen_string_literal
bug12031 = '[ruby-core:73561] [Bug #12031]'
e = @erb.new("<%#encoding: us-ascii%>a")
e.src.sub!(/\A#(?:-\*-)?(.*)(?:-\*-)?/) {
'# -*- \1; frozen-string-literal: true -*-'
}
assert_equal("a", e.result, bug12031)
%w(false true).each do |flag|
erb = @erb.new("<%#frozen-string-literal: #{flag}%><%=''.frozen?%>")
assert_equal(flag, erb.result)
end
end
def test_result_with_hash
erb = @erb.new("<%= foo %>")
assert_equal("1", erb.result_with_hash(foo: "1"))
end
def test_result_with_hash_does_not_use_caller_local_variables
erb = @erb.new("<%= foo %>")
foo = 1
assert_raise(NameError) { erb.result_with_hash({}) }
assert_equal("1", erb.result_with_hash(foo: foo))
end
def test_result_with_hash_does_not_modify_caller_binding
erb = @erb.new("<%= foo %>")
erb.result_with_hash(foo: "1")
assert_equal(false, binding.local_variable_defined?(:foo))
end
def test_result_with_hash_does_not_modify_toplevel_binding
erb = @erb.new("<%= foo %>")
erb.result_with_hash(foo: "1")
assert_equal(false, TOPLEVEL_BINDING.local_variable_defined?(:foo))
TOPLEVEL_BINDING.eval 'template2 = "two"'
erb = @erb.new("<%= template2 %>")
erb.result_with_hash(template2: "TWO")
assert_equal "two", TOPLEVEL_BINDING.local_variable_get("template2")
end
# This depends on the behavior that #local_variable_set raises TypeError by invalid key.
def test_result_with_hash_with_invalid_keys_raises_type_error
erb = @erb.new("<%= 1 %>")
assert_raise(TypeError) { erb.result_with_hash({ 1 => "1" }) }
end
# Bug#14243
def test_half_working_comment_backward_compatibility
assert_nothing_raised do
@erb.new("<% # comment %>\n").result
end
end
# [deprecated] These interfaces will be removed later
def test_deprecated_interface_warnings
[nil, 0, 1, 2].each do |safe|
assert_warn(/2nd argument of ERB.new is deprecated/) do
ERB.new('', safe)
end
end
[nil, '', '%', '%<>'].each do |trim|
assert_warn(/3rd argument of ERB.new is deprecated/) do
ERB.new('', nil, trim)
end
end
[nil, '_erbout', '_hamlout'].each do |eoutvar|
assert_warn(/4th argument of ERB.new is deprecated/) do
ERB.new('', nil, nil, eoutvar)
end
end
end
def test_prohibited_marshal_dump
erb = ERB.new("")
assert_raise(TypeError) {Marshal.dump(erb)}
end
def test_prohibited_marshal_load
erb = ERB.allocate
erb.instance_variable_set(:@src, "")
erb.instance_variable_set(:@lineno, 1)
erb.instance_variable_set(:@_init, true)
erb = Marshal.load(Marshal.dump(erb))
assert_raise(ArgumentError) {erb.result}
end
def test_multi_line_comment_lineno
erb = ERB.new(<<~EOS)
<%= __LINE__ %>
<%#
%><%= __LINE__ %>
EOS
assert_equal <<~EOS, erb.result
1
3
EOS
end
end
class TestERBCoreWOStrScan < TestERBCore
def setup
@save_map = ERB::Compiler::Scanner.instance_variable_get('@scanner_map')
map = {[nil, false]=>ERB::Compiler::SimpleScanner}
ERB::Compiler::Scanner.instance_variable_set('@scanner_map', map)
super
end
def teardown
ERB::Compiler::Scanner.instance_variable_set('@scanner_map', @save_map)
end
end