diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index 5157283b3a..ae040f90ad 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'uri' +require_relative 'compiled_template' module RDoc module Generator @@ -13,12 +14,37 @@ module Generator class Aliki < Generator::Darkfish DESCRIPTION = 'HTML generator, written by Stan Lo' + TEMPLATE_DIR = (Pathname.new(__dir__) + 'template' + 'aliki').freeze # :nodoc: + # Pages include the assembled head's inputs; absent page objects are nil. + PAGE_INPUTS = %i[io rel_prefix asset_rel_prefix current klass file].freeze # :nodoc: + TEMPLATE_INPUTS = { # :nodoc: + 'class.rhtml' => PAGE_INPUTS + [:breadcrumb], + 'index.rhtml' => PAGE_INPUTS, + 'page.rhtml' => PAGE_INPUTS, + 'servlet_not_found.rhtml' => PAGE_INPUTS + [:message], + 'servlet_root.rhtml' => PAGE_INPUTS + [:installed], + '_head.rhtml' => %i[rel_prefix asset_rel_prefix current klass file], + '_aside_toc.rhtml' => [], + '_footer.rhtml' => [:rel_prefix], + '_header.rhtml' => [:rel_prefix], + '_icons.rhtml' => [], + '_sidebar_ancestors.rhtml' => [:klass], + '_sidebar_classes.rhtml' => [:rel_prefix], + '_sidebar_extends.rhtml' => [:klass], + '_sidebar_includes.rhtml' => [:klass], + '_sidebar_installed.rhtml' => [:installed], + '_sidebar_methods.rhtml' => [:klass], + '_sidebar_pages.rhtml' => %i[rel_prefix current], + '_sidebar_search.rhtml' => [], + '_sidebar_sections.rhtml' => [:klass], + '_sidebar_toggle.rhtml' => [], + }.transform_values(&:freeze).freeze + RDoc.add_generator self def initialize(store, options) super - aliki_template_dir = File.expand_path(File.join(__dir__, 'template', 'aliki')) - @template_dir = Pathname.new(aliki_template_dir) + @template_dir = TEMPLATE_DIR end ## @@ -151,8 +177,51 @@ def resolve_url(rel_prefix, url) "#{rel_prefix}/#{url}" end + #: (Pathname, ?bool, ?Class[ERB]) -> ERB + def template_for(file, page = true, klass = ERB) + cached = @template_cache[file] + # String and streaming ERB compile different output operations. Replace + # only owned compiled templates; custom ERB caches keep legacy behavior. + if cached.is_a?(CompiledTemplate) && cached.class != klass + @template_cache.delete(file) + end + + template = super + return template if template.is_a?(CompiledTemplate) + return template unless [ERB, ERBIO, ERBPartial].include?(template.class) + + if inputs = builtin_template_inputs(file) + template.extend CompiledTemplate + template.generator = self + template.render_inputs = inputs + end + template + end + private + #: (Pathname, ?Pathname?, **untyped) -> untyped + def render_page_template(template_file, out_file = nil, **inputs, &block) + return super unless builtin_template_inputs(template_file) + + erb_class = erb_class_for out_file + template = template_for template_file, true, erb_class + return super unless template.is_a?(CompiledTemplate) + + render_template template_file, out_file do |io| + CompiledTemplate::Inputs.new(io: io, **inputs) + end + end + + #: (Pathname) -> Array[Symbol]? + def builtin_template_inputs(file) + # Custom generators and templates retain their shared-binding behavior. + return unless self.class == Aliki && @template_dir.expand_path == TEMPLATE_DIR + return unless file.dirname.expand_path == TEMPLATE_DIR + + TEMPLATE_INPUTS[file.basename.to_s] + end + def template_encoding ::Encoding::UTF_8 end diff --git a/lib/rdoc/generator/compiled_template.rb b/lib/rdoc/generator/compiled_template.rb new file mode 100644 index 0000000000..e8f372cde3 --- /dev/null +++ b/lib/rdoc/generator/compiled_template.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module RDoc + module Generator + # Caches a renderer with declared inputs for an Aliki template. Ordinary + # bindings still use ERB's original evaluation and shared-local semantics. + module CompiledTemplate # :nodoc: + + Inputs = Struct.new( + :io, :rel_prefix, :asset_rel_prefix, :current, :klass, :file, + :breadcrumb, :message, :installed, keyword_init: true + ) + + attr_writer :generator, :render_inputs + + #: (?(Binding | Inputs)?) -> untyped + def result(context = nil) + return super unless context.is_a?(Inputs) + + @compiled_renderer ||= def_module("render(#{@render_inputs.join(', ')})").instance_method(:render).bind(@generator) + @compiled_renderer.call(*@render_inputs.map { |name| context[name] }) + end + end + end +end diff --git a/lib/rdoc/generator/darkfish.rb b/lib/rdoc/generator/darkfish.rb index c097bab70c..5ce314b1d4 100644 --- a/lib/rdoc/generator/darkfish.rb +++ b/lib/rdoc/generator/darkfish.rb @@ -293,7 +293,8 @@ def generate_index @title = @options.title @main_page = @files.find { |f| f.full_name == @options.main_page } - render_template template_file, out_file do |io| + inputs = { rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, current: @main_page } + render_page_template template_file, out_file, **inputs do |io| here = binding # suppress 1.9.3 warning here.local_variable_set(:asset_rel_prefix, asset_rel_prefix) @@ -333,7 +334,11 @@ def generate_class(klass, template_file = nil) @title = "#{klass.type} #{klass.full_name} - #{@options.title}" debug_msg " rendering #{out_file}" - render_template template_file, out_file do |io| + inputs = { + rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, + current: current, klass: klass, breadcrumb: breadcrumb, + } + render_page_template template_file, out_file, **inputs do |io| here = binding # suppress 1.9.3 warning here.local_variable_set(:asset_rel_prefix, asset_rel_prefix) @@ -457,7 +462,11 @@ def generate_page(file) @title = "#{file.page_name} - #{@options.title}" debug_msg " rendering #{out_file}" - render_template template_file, out_file do |io| + inputs = { + rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, + current: current, file: file, + } + render_page_template template_file, out_file, **inputs do |io| here = binding # suppress 1.9.3 warning here.local_variable_set(:current, current) @@ -483,7 +492,8 @@ def generate_servlet_not_found(message) @title = 'Not Found' - render_template template_file do |io| + inputs = { rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, message: message } + render_page_template template_file, **inputs do |io| here = binding # suppress 1.9.3 warning here.local_variable_set(:asset_rel_prefix, asset_rel_prefix) @@ -513,7 +523,8 @@ def generate_servlet_root(installed) @title = 'Local RDoc Documentation' - render_template template_file do |io| binding end + inputs = { rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, installed: installed } + render_page_template template_file, **inputs do |io| binding end rescue => e error = Error.new \ "error generating servlet_root: #{e.message} (#{e.class})" @@ -642,12 +653,11 @@ def render(file_name) # An io will be yielded which must be captured by binding in the caller. def render_template(template_file, out_file = nil) # :yield: io - io_output = out_file && !@dry_run && @file_output - erb_klass = io_output ? ERBIO : ERB + erb_klass = erb_class_for out_file template = template_for template_file, true, erb_klass - if io_output + if erb_klass == ERBIO debug_msg "Outputting to %s" % [out_file.expand_path] out_file.dirname.mkpath @@ -809,6 +819,18 @@ def group_classes_by_namespace_for_sidebar(classes) private + #: (Pathname?) -> Class[ERB] + def erb_class_for(out_file) + out_file && !@dry_run && @file_output ? ERBIO : ERB + end + + # Themes can render with explicit inputs without changing the legacy + # render_template API or restricting the binding supplied by its caller. + #: (Pathname, ?Pathname?, **untyped) -> untyped + def render_page_template(template_file, out_file = nil, **inputs, &block) + render_template(template_file, out_file, &block) + end + def template_encoding nil end diff --git a/lib/rdoc/generator/template/aliki/_footer.rhtml b/lib/rdoc/generator/template/aliki/_footer.rhtml index ae2f831443..d64c3797d3 100644 --- a/lib/rdoc/generator/template/aliki/_footer.rhtml +++ b/lib/rdoc/generator/template/aliki/_footer.rhtml @@ -16,7 +16,7 @@ diff --git a/lib/rdoc/generator/template/aliki/_head.rhtml b/lib/rdoc/generator/template/aliki/_head.rhtml index d1ca4b7ad0..6957bf749c 100644 --- a/lib/rdoc/generator/template/aliki/_head.rhtml +++ b/lib/rdoc/generator/template/aliki/_head.rhtml @@ -3,7 +3,7 @@ <%= h @title %> -<%- if defined?(klass) %> +<%- if defined?(klass) && klass %> " @@ -19,7 +19,7 @@ content="<%= h "#{klass.type} #{klass.full_name}: #{excerpt(klass.comment)}" %>" > <%- end %> -<%- elsif defined?(file) %> +<%- elsif defined?(file) && file %> <%- if canonical_url = @options.canonical_root %> - <% canonical_url = current.canonical_url if defined?(current) %> + <% canonical_url = current.canonical_url if defined?(current) && current %> <%- end %> @@ -47,7 +47,7 @@ -<%- if defined?(klass) %> +<%- if defined?(klass) && klass %> <%- if klass.comment.empty? %> <%- end %> -<%- elsif defined?(file) %> +<%- elsif defined?(file) && file %> <%- if file.comment.empty? %> <%- if canonical_url = @options.canonical_root %> - <% canonical_url = current.canonical_url if defined?(current) %> + <% canonical_url = current.canonical_url if defined?(current) && current %> <%- end %> @@ -81,7 +81,7 @@ -<%- if defined?(klass) %> +<%- if defined?(klass) && klass %> <%- if klass.comment.empty? %> <%- end %> -<%- elsif defined?(file) %> +<%- elsif defined?(file) && file %> <%- if file.comment.empty? %> <%- @options.template_stylesheets.each do |stylesheet| %> <%- end %> diff --git a/lib/rdoc/generator/template/darkfish/_footer.rhtml b/lib/rdoc/generator/template/darkfish/_footer.rhtml index 3ccaa2d836..db483866ba 100644 --- a/lib/rdoc/generator/template/darkfish/_footer.rhtml +++ b/lib/rdoc/generator/template/darkfish/_footer.rhtml @@ -1,5 +1,5 @@ diff --git a/test/rdoc/generator/template_test.rb b/test/rdoc/generator/template_test.rb new file mode 100644 index 0000000000..3fdf65a959 --- /dev/null +++ b/test/rdoc/generator/template_test.rb @@ -0,0 +1,386 @@ +# frozen_string_literal: true +require_relative '../helper' + +class RDocGeneratorTemplateTest < RDoc::TestCase + + def setup + super + + @options.op_dir = @test_home + @top_level = @store.add_file 'example.rb' + @top_level.parser = RDoc::Parser::Ruby + @alpha = @top_level.add_class RDoc::NormalClass, 'Alpha' + @beta = @top_level.add_class RDoc::NormalClass, 'Beta' + @alpha.add_comment 'First page documentation: café.', @top_level + @beta.add_comment 'Second page documentation.', @top_level + @store.complete :private + end + + def test_builtin_pages_and_partials_are_evaluated_only_once + generator = generator_for 'aliki' + + first_evaluations = count_evaluations { generator.generate_class @alpha } + second_evaluations = count_evaluations { generator.generate_class @beta } + + assert_operator first_evaluations, :>, 0 + assert_equal 0, second_evaluations + end + + def test_darkfish_keeps_binding_based_rendering + generator = generator_for 'darkfish' + generator.generate_class @alpha + + assert_operator count_evaluations { generator.generate_class @beta }, :>, 0 + assert_kind_of Binding, generator.instance_variable_get(:@context) + generator.instance_variable_get(:@template_cache).each_value do |template| + assert_not_kind_of RDoc::Generator::CompiledTemplate, template + end + end + + def test_cached_renderers_are_methods_bound_to_the_generator + generator = generator_for 'aliki' + generator.generate_class @alpha + + templates = generator.instance_variable_get(:@template_cache).values + renderers = templates.filter_map { |template| template.instance_variable_get(:@compiled_renderer) } + + assert_not_empty renderers + renderers.each do |renderer| + assert_kind_of Method, renderer + assert_same generator, renderer.receiver + end + end + + def test_compiled_renderers_only_accept_declared_inputs + generator = generator_for 'aliki' + generator.generate_class @alpha + + page = generator.template_for generator.template_dir + 'class.rhtml' + renderer = page.instance_variable_get :@compiled_renderer + assert_kind_of Method, renderer + assert_equal %i[io rel_prefix asset_rel_prefix current klass file breadcrumb], + renderer.parameters.map(&:last) + + partial = generator.template_for generator.template_dir + '_sidebar_sections.rhtml', false, RDoc::ERBPartial + renderer = partial.instance_variable_get :@compiled_renderer + assert_kind_of Method, renderer + assert_equal [:klass], renderer.parameters.map(&:last) + end + + def test_aliki_contracts_match_its_owned_templates + generator = generator_for 'aliki' + templates = generator.template_dir.children.select { |file| file.extname == '.rhtml' }.map { |file| file.basename.to_s } + + assert_equal templates.sort, RDoc::Generator::Aliki::TEMPLATE_INPUTS.keys.sort + end + + def test_builtin_rendering_does_not_inspect_bindings + generator = generator_for 'aliki' + binding_reads = [] + trace = TracePoint.new(:call, :c_call) do |event| + if event.self.is_a?(Binding) && %i[local_variables local_variable_get].include?(event.method_id) + binding_reads << event.method_id + end + end + + trace.enable do + generator.generate_class @alpha + generator.generate_class @beta + end + + assert_empty binding_reads + end + + def test_clearing_the_template_cache_discards_its_compiled_renderers + generator = generator_for 'aliki' + generator.generate_class @alpha + file = generator.template_dir + 'class.rhtml' + first_template = generator.template_for file + + generator.instance_variable_get(:@template_cache).clear + evaluations = count_evaluations { generator.generate_class @beta } + + assert_operator evaluations, :>, 0 + assert_not_same first_template, generator.template_for(file) + assert_equal 0, count_evaluations { generator.generate_class @alpha } + end + + def test_template_result_still_accepts_an_implicit_binding + generator = generator_for 'aliki' + template = generator.template_for generator.template_dir + '_sidebar_toggle.rhtml', false, RDoc::ERBPartial + + assert_include template.result, 'sidebar-navigation-toggle' + end + + def test_template_result_preserves_a_foreign_binding + generator = generator_for 'aliki' + template = generator.template_for generator.template_dir + '_sidebar_toggle.rhtml', false, RDoc::ERBPartial + + assert_include template.result(binding), 'sidebar-navigation-toggle' + assert_nil template.instance_variable_get(:@compiled_renderer) + end + + def test_cached_templates_use_current_class_and_path + %w[aliki darkfish].each do |theme| + generator = generator_for theme + alpha_html = generator.generate_class @alpha + beta_html = generator.generate_class @beta + + assert_include alpha_html, 'class Alpha -' + assert_include alpha_html, 'First page documentation: café.' + assert_include beta_html, '<title>class Beta -' + assert_include beta_html, 'Second page documentation.' + assert_not_include beta_html, 'First page documentation: café.' + assert_include beta_html, RDoc::VERSION + end + end + + def test_cached_templates_write_to_current_output_stream + generator = generator_for 'aliki' + generator.file_output = true + generator.generate_class @alpha + generator.generate_class @beta + + alpha_html = File.read File.join(@test_home, 'Alpha.html') + beta_html = File.read File.join(@test_home, 'Beta.html') + assert_include alpha_html, 'First page documentation: café.' + assert_include beta_html, 'Second page documentation.' + assert_not_include beta_html, 'First page documentation: café.' + end + + def test_cached_templates_follow_output_mode_changes + generator = generator_for 'aliki' + generator.generate_class @alpha + + generator.file_output = true + generator.generate_class @beta + beta_html = File.read File.join(@test_home, 'Beta.html') + assert_include beta_html, 'Second page documentation.' + + generator.file_output = false + assert_include generator.generate_class(@alpha), 'First page documentation: café.' + assert_equal 0, count_evaluations { generator.generate_class @beta } + end + + def test_cached_streaming_templates_can_switch_to_dry_run + generator = generator_for 'aliki' + generator.file_output = true + generator.generate_class @alpha + + generator.dry_run = true + html = generator.generate_class @beta + + assert_include html, 'Second page documentation.' + refute_file File.join(@test_home, 'Beta.html') + assert_equal 0, count_evaluations { generator.generate_class @alpha } + end + + def test_index_reuses_its_renderer_when_the_main_page_changes + generator = generator_for 'aliki' + html = generator.generate_index + assert_include html, 'This is the API documentation' + + template = generator.template_for generator.template_dir + 'index.rhtml' + renderer = template.instance_variable_get :@compiled_renderer + assert_kind_of Method, renderer + + readme = @store.add_file 'README.rdoc', parser: RDoc::Parser::Simple + readme.comment = "= Main page heading\n\n== Second heading" + @options.main_page = readme.full_name + generator.refresh_store_data + html = generator.generate_index + assert_include html, 'Main page heading' + assert_same renderer, template.instance_variable_get(:@compiled_renderer) + + @options.main_page = nil + html = generator.generate_index + assert_include html, 'This is the API documentation' + assert_not_include html, '<h1 id="main-page-heading"' + assert_same renderer, template.instance_variable_get(:@compiled_renderer) + end + + def test_partials_reuse_their_renderer_across_page_types + page = @store.add_file 'guides/USAGE.rdoc', parser: RDoc::Parser::Simple + page.comment = '= Nested page documentation' + + generator = generator_for 'aliki' + generator.generate_index + partial = generator.template_for generator.template_dir + '_sidebar_pages.rhtml', false, RDoc::ERBPartial + renderer = partial.instance_variable_get :@compiled_renderer + assert_kind_of Method, renderer + + generator.generate_class @alpha + assert_same renderer, partial.instance_variable_get(:@compiled_renderer) + + html = generator.generate_page page + assert_include html, 'Nested page documentation' + assert_include html, '../css/rdoc.css' + assert_same renderer, partial.instance_variable_get(:@compiled_renderer) + + html = generator.generate_servlet_not_found 'Missing page' + assert_include html, 'Missing page' + assert_same renderer, partial.instance_variable_get(:@compiled_renderer) + assert_equal 0, count_evaluations { generator.generate_class @beta } + end + + def test_cached_templates_work_in_dry_run_mode + generator = generator_for 'aliki' + generator.file_output = true + generator.dry_run = true + generator.generate_class @alpha + + evaluations = count_evaluations { generator.generate_class @beta } + + assert_equal 0, evaluations + refute_file File.join(@test_home, 'Alpha.html') + refute_file File.join(@test_home, 'Beta.html') + end + + def test_custom_templates_preserve_shared_binding_mutations + @options.template_dir = @test_home + generator = RDoc::Generator::Darkfish.new @store, @options + generator.file_output = false + + page = Pathname.new(@test_home) + 'custom.rhtml' + File.write page, '<html><% page_value = "changed" %><%= render "_custom.rhtml" %></html>' + File.write File.join(@test_home, '_custom.rhtml'), '<%= page_value %>' + context = generator.instance_eval { binding } + context.local_variable_set :page_value, 'original' + + 2.times do |index| + html = generator.render_template(page) { context } + assert_equal "<html>#{'changed' * (index + 1)}</html>", html + assert_equal 'changed', context.local_variable_get(:page_value) + end + end + + def test_custom_class_templates_can_access_incidental_caller_locals + generator = generator_for 'aliki' + page = Pathname.new(@test_home) + 'custom.rhtml' + File.write page, <<~ERB + <html><%= [klass.equal?(current), out_file.basename.to_s, + template_file.basename.to_s, here.is_a?(Binding), + search_index_rel_prefix == rel_prefix].join('|') %></html> + ERB + + assert_equal "<html>true|Alpha.html|custom.rhtml|true|true</html>\n", + generator.generate_class(@alpha, page) + end + + def test_builtin_templates_preserve_explicit_binding_evaluation_without_inputs + generator = generator_for 'aliki' + generator.generate_index + template_file = generator.template_dir + 'index.rhtml' + template = generator.template_for template_file + renderer = template.instance_variable_get :@compiled_renderer + context = generator.instance_eval { binding } + context.local_variable_set :rel_prefix, '.' + context.local_variable_set :asset_rel_prefix, '.' + + html = generator.render_template(template_file) { context } + + assert_include html, 'This is the API documentation' + assert_equal html, context.local_variable_get(:io) + assert_same renderer, template.instance_variable_get(:@compiled_renderer) + end + + def test_custom_page_using_builtin_partials_keeps_shared_binding_behavior + generator = generator_for 'aliki' + generator.generate_class @beta + page = Pathname.new(@test_home) + 'custom.rhtml' + File.write page, '<html><% render "_footer.rhtml" %><%= @context.local_variable_get(:_erbout__footer) %></html>' + + html = generator.generate_class @alpha, page + + assert_include html, 'Generated by' + assert_include html, RDoc::VERSION + partial = generator.template_for generator.template_dir + '_footer.rhtml', false, RDoc::ERBPartial + assert_kind_of Method, partial.instance_variable_get(:@compiled_renderer) + assert_kind_of Binding, generator.instance_variable_get(:@context) + end + + def test_custom_erb_classes_keep_their_result_implementation + generator = generator_for 'aliki' + erb_class = Class.new(ERB) do + def result(context = nil) + 'custom result' + end + end + template = generator.template_for generator.template_dir + '_sidebar_toggle.rhtml', false, erb_class + + assert_equal 'custom result', template.result(generator.instance_eval { binding }) + end + + def test_cached_custom_erb_classes_receive_the_original_page_binding + generator = generator_for 'aliki' + erb_class = Class.new(ERB) do + def result(context = nil) + "custom #{context.local_variable_get(:current).full_name}" + end + end + generator.template_for generator.template_dir + 'class.rhtml', true, erb_class + + assert_equal 'custom Alpha', generator.generate_class(@alpha) + assert_equal 'custom Beta', generator.generate_class(@beta) + end + + def test_render_inputs_reject_unknown_fields + assert_raise(ArgumentError) do + RDoc::Generator::CompiledTemplate::Inputs.new(incidental_local: true) + end + end + + def test_generator_subclasses_keep_binding_based_rendering + generator = generator_for 'aliki', Class.new(RDoc::Generator::Aliki) + generator.generate_class @alpha + + evaluations = count_evaluations { generator.generate_class @beta } + + assert_operator evaluations, :>, 0 + end + + def test_generator_subclasses_can_override_the_legacy_render_template_signature + generator_class = Class.new(RDoc::Generator::Aliki) do + def render_template(template_file, out_file = nil, &block) + super + end + end + generator = generator_for 'aliki', generator_class + + assert_include generator.generate_class(@alpha), 'First page documentation: café.' + end + + def test_template_errors_keep_the_template_filename + generator = generator_for 'aliki' + @alpha.define_singleton_method(:description) { self.no_such_method } + + error = assert_raise(RDoc::Error) { generator.generate_class @alpha } + + assert_include error.message, 'class.rhtml' + file = generator.template_dir + 'class.rhtml' + line = generator.assemble_template(file).lines.index { |text| text.include?('klass.description') } + 1 + assert error.backtrace.any? { |entry| entry.include?("#{file}:#{line}:") } + end + + private + + def generator_for(theme, generator_class = nil) + generator_class ||= theme == 'aliki' ? RDoc::Generator::Aliki : RDoc::Generator::Darkfish + @options.template_dir = File.join @pwd, 'lib/rdoc/generator/template', theme + generator = generator_class.new @store, @options + @rdoc.generator = generator + generator.file_output = false + generator.setup + generator + end + + def count_evaluations + count = 0 + erb_file = ERB.instance_method(:result).source_location.first + trace = TracePoint.new(:line) do |event| + count += 1 if event.path == erb_file && %i[def_method result].include?(event.method_id) + end + trace.enable { yield } + count + end +end diff --git a/test/rdoc/rdoc_server_test.rb b/test/rdoc/rdoc_server_test.rb index 9f9a715713..283abc23ac 100644 --- a/test/rdoc/rdoc_server_test.rb +++ b/test/rdoc/rdoc_server_test.rb @@ -79,6 +79,45 @@ def test_route_returns_404_for_missing_page assert_equal 'text/html', content_type end + def test_cached_templates_render_updated_and_new_classes_after_reparse + with_running_server do |port| + original = get port, '/Example.html' + assert_equal '200', original.code + assert_include original.body, 'method-i-greet' + + generator = @server.instance_variable_get :@generator + template = generator.template_for generator.template_dir + 'class.rhtml' + renderer = template.instance_variable_get :@compiled_renderer + assert_kind_of Method, renderer + + File.write File.join(@dir, 'example.rb'), <<~RUBY + # Updated documentation from reparsed source. + class Example + def changed + end + end + + # Documentation for a newly added class. + class Another + end + RUBY + + wait_for('cached class page to reflect reparsed source') do + get(port, '/Example.html').body.include?('Updated documentation from reparsed source.') + end + + updated = get port, '/Example.html' + assert_include updated.body, 'method-i-changed' + assert_not_include updated.body, 'method-i-greet' + + added = get port, '/Another.html' + assert_equal '200', added.code + assert_include added.body, 'Documentation for a newly added class.' + assert_not_include added.body, 'Updated documentation from reparsed source.' + assert_same renderer, template.instance_variable_get(:@compiled_renderer) + end + end + def test_check_for_changes_parses_and_reloads_rbs_signatures with_running_server do |port| sig_dir = File.join @dir, 'sig'