Skip to content

Commit

Permalink
Support coverable templates
Browse files Browse the repository at this point in the history
Ruby's coverage library does not support code loaded eval (and
similar methods like {class,module,instance}_{eval,exec}). However,
it would be nice to be able to get code coverage for your web
application views. One simple way to do this is to write a file
with the compiled template code, and then load it

This adds Tilt::Template#compiled_path=, for setting a path
for the template.  There may be multiple compiled files based
on this path if the template is render with multiple scope classes
and/or local variables.

load is used instead of require to prevent breaking things when
the same path would be reused.  The methods are unbound after
the file is loaded, so using load instead of require makes sense
for that.  We do not want to delete the file after loading, since
the main reason to use this feature is for coverage testing.

Trying this in a real application showed indentation warnings in
verbose warning mode.  Fix those by not indenting the compiled
template method preamble.

In my testing, the coverage library didn't pickup relative loads
by default, so this uses File.expand_path to expand the paths
passed on Tilt::Template#compiled_path=.  That's not required,
but it seems useful enough to make it the default behavior.
  • Loading branch information
jeremyevans authored and judofyr committed Aug 27, 2022
1 parent d8a3999 commit 384553f
Show file tree
Hide file tree
Showing 2 changed files with 122 additions and 6 deletions.
64 changes: 58 additions & 6 deletions lib/tilt/template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class Template
# interface.
attr_reader :options

# A path ending in .rb that the template code will be written to, then
# required, instead of being evaled. This is useful for determining
# coverage of compiled template code, or to use static analysis tools
# on the compiled template code.
attr_reader :compiled_path

class << self
# An empty Hash that the template engine can populate with various
# metadata.
Expand Down Expand Up @@ -130,6 +136,17 @@ def metadata
end
end

# Set the prefix to use for compiled paths.
def compiled_path=(path)
if path
# Use expanded paths when loading, since that is helpful
# for coverage. Remove any .rb suffix, since that will
# be added back later.
path = File.expand_path(path.sub(/\.rb\z/i, ''))
end
@compiled_path = path
end

protected

# @!group For template implementations
Expand Down Expand Up @@ -259,18 +276,53 @@ def compile_template_method(local_keys, scope_class=nil)
method_source.force_encoding(source.encoding)
end

method_source << <<-RUBY
TOPOBJECT.class_eval do
def #{method_name}(locals)
#{local_code}
RUBY
# Don't indent method source, to avoid indentation warnings when using compiled paths
method_source << "::Tilt::TOPOBJECT.class_eval do\ndef #{method_name}(locals)\n#{local_code}\n"
offset += method_source.count("\n")
method_source << source
method_source << "\nend;end;"
(scope_class || Object).class_eval(method_source, eval_file, line - offset)

bind_compiled_method(method_source, offset, scope_class, local_keys)
unbind_compiled_method(method_name)
end

def bind_compiled_method(method_source, offset, scope_class, local_keys)
path = compiled_path
if path && scope_class.name
path = path.dup

if defined?(@compiled_path_counter)
path << '-' << @compiled_path_counter.succ!
else
@compiled_path_counter = "0".dup
end
path << ".rb"

# Wrap method source in a class block for the scope, so constant lookup works
method_source = "class #{scope_class.name}\n#{method_source}\nend"

load_compiled_method(path, method_source)
else
if path
warn "compiled_path (#{compiled_path.inspect}) ignored on template with anonymous scope_class (#{scope_class.inspect})"
end

eval_compiled_method(method_source, offset, scope_class)
end
end

def eval_compiled_method(method_source, offset, scope_class)
(scope_class || Object).class_eval(method_source, eval_file, line - offset)
end

def load_compiled_method(path, method_source)
File.binwrite(path, method_source)

# Use load and not require, so unbind_compiled_method does not
# break if the same path is used more than once.
load path
end

def unbind_compiled_method(method_name)
method = TOPOBJECT.instance_method(method_name)
TOPOBJECT.class_eval { remove_method(method_name) }
Expand Down
64 changes: 64 additions & 0 deletions test/tilt_template_test.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require_relative 'test_helper'
require 'tempfile'
require 'tmpdir'
require 'pathname'

_MockTemplate = Class.new(Tilt::Template) do
Expand Down Expand Up @@ -148,6 +149,69 @@ def precompiled_template(locals)
assert inst.prepared?
end

it "template with compiled_path" do
Dir.mktmpdir('tilt') do |dir|
base = File.join(dir, 'template')
inst = _SourceGeneratingMockTemplate.new { |t| 'Hey' }
inst.compiled_path = base

tempfile = "#{base}.rb"
assert_equal false, File.file?(tempfile)
assert_equal 'Hey', inst.render
assert_equal true, File.file?(tempfile)
assert_match(/\Aclass Object/, File.read(tempfile))

tempfile = "#{base}-1.rb"
assert_equal false, File.file?(tempfile)
assert_equal 'Hey', inst.render("")
assert_equal true, File.file?(tempfile)
assert_match(/\Aclass String/, File.read(tempfile))

tempfile = "#{base}-2.rb"
assert_equal false, File.file?(tempfile)
assert_equal 'Hey', inst.render(Tilt::Mapping.new)
assert_equal true, File.file?(tempfile)
assert_match(/\Aclass Tilt::Mapping/, File.read(tempfile))
end
end

it "template with compiled_path and with anonymous scope_class" do
Dir.mktmpdir('tilt') do |dir|
base = File.join(dir, 'template')
inst = _SourceGeneratingMockTemplate.new { |t| 'Hey' }
inst.compiled_path = base

message = nil
inst.define_singleton_method(:warn) { |msg| message = msg }
scope_class = Class.new
assert_equal 'Hey', inst.render(scope_class.new)
assert_equal "compiled_path (#{base.inspect}) ignored on template with anonymous scope_class (#{scope_class.inspect})", message
assert_equal [], Dir.new(dir).children
end
end

it "template with compiled_path with locals" do
Dir.mktmpdir('tilt') do |dir|
base = File.join(dir, 'template')
inst = _SourceGeneratingMockTemplate.new { |t| 'Hey' }
inst.compiled_path = base + '.rb'

tempfile = "#{base}.rb"
assert_equal false, File.file?(tempfile)
assert_equal 'Hey', inst.render(Object.new, 'a' => 1)
content = File.read(tempfile)
assert_match(/\Aclass Object/, content)
assert_includes(content, "\na = locals[\"a\"]\n")

tempfile = "#{base}-1.rb"
assert_equal false, File.file?(tempfile)
assert_equal 'Hey', inst.render(Object.new, 'b' => 1, 'a' => 1)
content = File.read(tempfile)
assert_match(/\Aclass Object/, content)
assert_includes(content, "\na = locals[\"a\"]\nb = locals[\"b\"]\n")
end
end

_CustomGeneratingMockTemplate = Class.new(_PreparingMockTemplate) do
def precompiled_template(locals)
data
Expand Down

0 comments on commit 384553f

Please sign in to comment.