From ea09fd4473298c3d308bf53a52f68db935a1d074 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 17 Mar 2026 19:57:56 +0100 Subject: [PATCH 1/2] Ruby: Analyze and trace `<%= render %>` partial calls --- lib/herb.rb | 9 + lib/herb/ast/erb_render_node.rb | 155 +++ lib/herb/cli.rb | 166 ++- lib/herb/engine.rb | 1 + .../engine/validators/render_validator.rb | 92 ++ lib/herb/render_checker.rb | 1008 +++++++++++++++++ test/engine/render_nodes_test.rb | 129 +++ test/render_checker_test.rb | 345 ++++++ 8 files changed, 1888 insertions(+), 17 deletions(-) create mode 100644 lib/herb/ast/erb_render_node.rb create mode 100644 lib/herb/engine/validators/render_validator.rb create mode 100644 lib/herb/render_checker.rb create mode 100644 test/engine/render_nodes_test.rb create mode 100644 test/render_checker_test.rb diff --git a/lib/herb.rb b/lib/herb.rb index c108f39c8..d7441631c 100644 --- a/lib/herb.rb +++ b/lib/herb.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true # typed: false +module Herb + PARTIAL_EXTENSIONS = [ + ".html.erb", ".html.herb", ".erb", ".herb", ".turbo_stream.erb", ".turbo_stream.herb" + ].freeze + + PARTIAL_GLOB_PATTERN = "_*.{html.erb,html.herb,erb,herb,turbo_stream.erb,turbo_stream.herb}" +end + require_relative "herb/colors" require_relative "herb/range" require_relative "herb/position" @@ -18,6 +26,7 @@ require_relative "herb/ast/node" require_relative "herb/ast/nodes" require_relative "herb/ast/helpers" +require_relative "herb/ast/erb_render_node" require_relative "herb/errors" require_relative "herb/warnings" diff --git a/lib/herb/ast/erb_render_node.rb b/lib/herb/ast/erb_render_node.rb new file mode 100644 index 000000000..f1f01f215 --- /dev/null +++ b/lib/herb/ast/erb_render_node.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "did_you_mean" + +module Herb + module AST + class ERBRenderNode < Node + PARTIAL_EXTENSIONS = Herb::PARTIAL_EXTENSIONS + + def static_partial? + partial && !partial.value.empty? + end + + def dynamic? + !static_partial? && (object || renderable) + end + + def partial_path + partial&.value + end + + def template_name + template_path&.value + end + + def layout_name + layout&.value + end + + def local_names + locals.map { |local| local.name&.value }.compact + end + + def resolve(view_root: nil, source_directory: nil) + name = partial_path || template_name + + return nil unless name + + view_root = Pathname.new(view_root) unless view_root.nil? || view_root.is_a?(Pathname) + + candidates = candidate_paths(name, view_root, source_directory) + candidates.find(&:exist?) + end + + def candidate_paths(name = nil, view_root = nil, source_directory = nil) + name ||= partial_path || template_name + + return [] unless name + + view_root = Pathname.new(view_root) unless view_root.nil? || view_root.is_a?(Pathname) + + directory = File.dirname(name) if name.include?("/") + base = name.include?("/") ? File.basename(name) : name + source_directory = Pathname.new(source_directory) if source_directory && !source_directory.is_a?(Pathname) + + PARTIAL_EXTENSIONS.flat_map do |extension| + paths = [] + + if directory + paths << view_root.join(directory, "_#{base}#{extension}") if view_root + else + paths << source_directory.join("_#{base}#{extension}") if source_directory + paths << view_root.join("_#{base}#{extension}") if view_root + end + + paths + end + end + + def similar_partials(view_root: nil, source_directory: nil, limit: 3) + name = partial_path || template_name + + return [] unless name + + suggestions = [] + + if view_root + view_root = Pathname.new(view_root) unless view_root.is_a?(Pathname) + + if view_root.directory? + all_partials = Dir[File.join(view_root, "**", Herb::PARTIAL_GLOB_PATTERN)].map do |file| + relative = Pathname.new(file).relative_path_from(view_root).to_s + relative.sub(%r{(^|/)_}, '\1').sub(/\..*\z/, "") + end + + spell_checker = DidYouMean::SpellChecker.new(dictionary: all_partials) + suggestions = spell_checker.correct(name).first(limit) + end + elsif source_directory + source_directory = Pathname.new(source_directory) unless source_directory.is_a?(Pathname) + + if source_directory.directory? + local_partials = Dir[File.join(source_directory, Herb::PARTIAL_GLOB_PATTERN)].map do |file| + File.basename(file).sub(/\A_/, "").sub(/\..*\z/, "") + end + + unless local_partials.empty? + spell_checker = DidYouMean::SpellChecker.new(dictionary: local_partials) + suggestions = spell_checker.correct(name).first(limit) + end + end + end + + if suggestions.empty? + suggestions.concat(find_non_partial_matches(name, view_root, source_directory)) + end + + suggestions + end + + def find_non_partial_matches(name = nil, view_root = nil, source_directory = nil) + name ||= partial_path || template_name + + return [] unless name + + matches = [] + + PARTIAL_EXTENSIONS.each do |extension| + if name.include?("/") + next unless view_root + + view_root = Pathname.new(view_root) unless view_root.is_a?(Pathname) + directory = File.dirname(name) + base = File.basename(name) + non_partial_path = view_root.join(directory, "#{base}#{extension}") + + if non_partial_path.exist? + matches << "#{name}#{extension} exists as a template, not a partial. Rename to _#{base}#{extension} to use it with render" + end + else + if source_directory + source_directory = Pathname.new(source_directory) unless source_directory.is_a?(Pathname) + non_partial_path = source_directory.join("#{name}#{extension}") + + if non_partial_path.exist? + matches << "#{name}#{extension} exists as a template, not a partial. Rename to _#{name}#{extension} to use it with render" + end + end + + if view_root + view_root = Pathname.new(view_root) unless view_root.is_a?(Pathname) + non_partial_path = view_root.join("#{name}#{extension}") + + if non_partial_path.exist? + matches << "#{name}#{extension} exists as a template, not a partial. Rename to _#{name}#{extension} to use it with render" + end + end + end + end + + matches.uniq + end + end + end +end diff --git a/lib/herb/cli.rb b/lib/herb/cli.rb index 914db0385..f68e16150 100644 --- a/lib/herb/cli.rb +++ b/lib/herb/cli.rb @@ -90,23 +90,28 @@ def help(exit_code = 0) bundle exec herb [command] [options] Commands: - bundle exec herb lex [file] Lex a file. - bundle exec herb parse [file] Parse a file. - bundle exec herb compile [file] Compile ERB template to Ruby code. - bundle exec herb render [file] Compile and render ERB template to final output. - bundle exec herb analyze [path] Analyze a project by passing a directory to the root of the project - bundle exec herb report [file] Generate a Markdown bug report for a file - bundle exec herb config [path] Show configuration and file patterns for a project - bundle exec herb ruby [file] Extract Ruby from a file. - bundle exec herb html [file] Extract HTML from a file. - bundle exec herb playground [file] Open the content of the source file in the playground - bundle exec herb version Prints the versions of the Herb gem and the libherb library. - - bundle exec herb lint [patterns] Lint templates (delegates to @herb-tools/linter) - bundle exec herb format [patterns] Format templates (delegates to @herb-tools/formatter) - bundle exec herb highlight [file] Syntax highlight templates (delegates to @herb-tools/highlighter) - bundle exec herb print [file] Print AST (delegates to @herb-tools/printer) - bundle exec herb lsp Start the language server (delegates to @herb-tools/language-server) + bundle exec herb lex [file] Lex a file. + bundle exec herb parse [file] Parse a file. + bundle exec herb compile [file] Compile ERB template to Ruby code. + bundle exec herb render [file] Compile and render ERB template to final output. + bundle exec herb analyze [path] Analyze a project by passing a directory to the root of the project. + bundle exec herb report [file] Generate a Markdown bug report for a file. + bundle exec herb config [path] Show configuration and file patterns for a project. + bundle exec herb ruby [file] Extract Ruby from a file. + bundle exec herb html [file] Extract HTML from a file. + bundle exec herb playground [file] Open the content of the source file in the playground. + bundle exec herb version Prints the versions of the Herb gem and the libherb library. + + bundle exec herb actionview check [path] Check if render calls resolve to valid partial files. + bundle exec herb actionview graph [path] Show render dependency graph for a project or file. + bundle exec herb actionview compile [file] Compile ERB template with ActionView framework support. + bundle exec herb actionview render [file] Render ERB template using ActionView helpers. + + bundle exec herb lint [patterns] Lint templates (delegates to @herb-tools/linter) + bundle exec herb format [patterns] Format templates (delegates to @herb-tools/formatter) + bundle exec herb highlight [file] Syntax highlight templates (delegates to @herb-tools/highlighter) + bundle exec herb print [file] Print AST (delegates to @herb-tools/printer) + bundle exec herb lsp Start the language server (delegates to @herb-tools/language-server) stdin: Commands that accept [file] also accept input via stdin: @@ -197,6 +202,8 @@ def result system(%(open "#{url}##{hash}")) exit(0) end + when "actionview" + run_actionview_command when "lint" run_node_tool("herb-lint", "@herb-tools/linter") when "format" @@ -334,6 +341,129 @@ def find_node_binary(name) nil end + def run_actionview_command + subcommand = @args[1] + @file = @args[2] + + case subcommand + when "check" + require_relative "render_checker" + + path = @file || "." + + unless File.directory?(path) + puts "Not a directory: '#{path}'." + exit(1) + end + + checker = Herb::RenderChecker.new(path) + has_issues = checker.check! + exit(has_issues ? 1 : 0) + when "graph" + require_relative "render_checker" + + path = @file || "." + + unless File.directory?(path) || File.file?(path) + puts "Not a file or directory: '#{path}'." + exit(1) + end + + checker = Herb::RenderChecker.new(File.directory?(path) ? path : File.dirname(path)) + + if File.file?(path) + checker.graph_file!(path) + else + checker.graph! + end + + exit(0) + when "compile" + @file = @args[2] + @action_view = true + compile_template + when "render" + @file = @args[2] + actionview_render + when nil, "help" + puts <<~HELP + Herb ActionView Commands + + Usage: + bundle exec herb actionview [subcommand] [options] + + Subcommands: + check [path] Check if render calls resolve to valid partial files + graph [path] Show render dependency graph for a project or file + compile [file] Compile ERB template with ActionView framework support + render [file] Render ERB template using ActionView helpers + + Examples: + bundle exec herb actionview check . + bundle exec herb actionview graph . + bundle exec herb actionview graph app/views/posts/show.html.erb + bundle exec herb actionview compile app/views/posts/show.html.erb + bundle exec herb actionview render app/views/posts/show.html.erb + + HELP + exit(0) + else + puts "Unknown actionview subcommand: '#{subcommand}'" + puts "Run 'herb actionview help' for available subcommands." + exit(1) + end + end + + def actionview_render + require "action_view" + + source = file_content + + lookup_context = ActionView::LookupContext.new([]) + view = ActionView::Base.with_empty_template_cache.new(lookup_context, {}, nil) + handler = ActionView::Template::Handlers::ERB.new + + template = ActionView::Template.new( + source, + @file || "(eval)", + handler, + locals: [], + format: :html + ) + + rendered = template.render(view, {}) + + if json + puts({ success: true, output: rendered, source: source }.to_json) + elsif silent + puts "Success" + else + puts rendered + end + + exit(0) + rescue LoadError + puts "Error: ActionView is required for 'herb actionview render'." + puts "" + puts "Add it to your Gemfile:" + puts " gem 'actionview'" + puts "" + puts "Or install it directly:" + puts " gem install actionview" + exit(1) + rescue StandardError => e + if json + puts({ success: false, error: e.message, source: source }.to_json) + elsif silent + puts "Failed" + else + puts "Error: #{e.class}: #{e.message}" + puts e.backtrace.first(5).map { |line| " #{line}" }.join("\n") + end + + exit(1) + end + def node_available? system("which node > /dev/null 2>&1") end @@ -443,6 +573,7 @@ def compile_template end options[:validate_ruby] = true + options[:framework] = :action_view if @action_view engine = Herb::Engine.new(file_content, options) if json @@ -529,6 +660,7 @@ def render_template options[:debug_filename] = @file if @file end + options[:framework] = :action_view if @action_view engine = Herb::Engine.new(file_content, options) compiled_code = engine.src diff --git a/lib/herb/engine.rb b/lib/herb/engine.rb index f42803909..3b2b24419 100644 --- a/lib/herb/engine.rb +++ b/lib/herb/engine.rb @@ -14,6 +14,7 @@ require_relative "engine/validators/security_validator" require_relative "engine/validators/nesting_validator" require_relative "engine/validators/accessibility_validator" +require_relative "engine/validators/render_validator" module Herb class Engine diff --git a/lib/herb/engine/validators/render_validator.rb b/lib/herb/engine/validators/render_validator.rb new file mode 100644 index 000000000..51bfc79b2 --- /dev/null +++ b/lib/herb/engine/validators/render_validator.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require_relative "../validator" + +module Herb + class Engine + module Validators + class RenderValidator < Validator + def initialize(enabled: true, filename: nil, project_path: nil) + super(enabled: enabled) + + @filename = filename + @project_path = project_path + @view_root = find_view_root + end + + def visit_erb_render_node(node) + if node.dynamic? + warning( + "Dynamic render call cannot be statically resolved", + node.location, + code: "RenderDynamic", + source: "RenderValidator" + ) + elsif node.static_partial? + validate_partial_exists(node) + end + + super + end + + private + + def validate_partial_exists(node) + return unless @filename + + source_directory = @project_path.join(@filename).dirname + resolved = node.resolve(view_root: @view_root, source_directory: source_directory) + + return if resolved + + message = "Partial '#{node.partial_path}' could not be resolved." + searched = node.candidate_paths(nil, @view_root, source_directory) + + if searched.any? + relative_paths = searched.map { |path| relative_to_project(path) }.uniq + message += "\n Looked in:\n" + + relative_paths.each do |path| + message += " - #{path}\n" + end + end + + suggestions = node.similar_partials(view_root: @view_root, source_directory: source_directory) + + if suggestions.any? + partial_suggestions, hint_suggestions = suggestions.partition { |suggestion| !suggestion.include?("exists as a template") } + + if partial_suggestions.any? + message += " Did you mean: #{partial_suggestions.map { |suggestion| "'#{suggestion}'" }.join(", ")}?\n" + end + + hint_suggestions.each do |hint| + message += "\n Note: #{hint}\n" + end + end + + error( + message, + node.location, + code: "RenderUnresolved", + source: "RenderValidator" + ) + end + + def find_view_root + return nil unless @project_path + + view_root = @project_path.join("app", "views") + + view_root.directory? ? view_root : nil + end + + def relative_to_project(path) + path.relative_path_from(@project_path).to_s + rescue ArgumentError + path.to_s + end + end + end + end +end diff --git a/lib/herb/render_checker.rb b/lib/herb/render_checker.rb new file mode 100644 index 000000000..b83e205c7 --- /dev/null +++ b/lib/herb/render_checker.rb @@ -0,0 +1,1008 @@ +# frozen_string_literal: true + +require "pathname" +require "set" + +module Herb + class RenderChecker + include Colors + + Result = Struct.new( + :render_calls, :dynamic_calls, :partial_files, + :unresolved, :unused, :view_root, + keyword_init: true + ) do + def issues? + unresolved.any? || unused.any? + end + end + + attr_reader :project_path, :configuration + + def initialize(project_path, configuration: nil) + @project_path = Pathname.new(File.expand_path(project_path)) + @configuration = configuration || Configuration.load(@project_path.to_s) + end + + def check! + start_time = Time.now + + erb_files = find_erb_files + view_root = find_view_root + + if erb_files.empty? + puts "No ERB files found." + return false + end + + puts "" + puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" + puts "" + + if configuration.config_path + puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}" + else + puts dimmed("No .herb.yml found, using defaults") + end + + puts dimmed("Checking render calls in #{erb_files.count} #{pluralize(erb_files.count, "file")}...") + + result = analyze(erb_files, view_root) + duration = Time.now - start_time + + print_results(result, duration) + + result.issues? + end + + def graph_file!(file_path) + erb_files = find_erb_files + view_root = find_view_root + + if erb_files.empty? + puts "No ERB files found in project." + return + end + + render_calls_by_file = collect_render_calls_by_file(erb_files) + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + all_render_calls = render_calls_by_file.values.flatten + _, dynamic_calls = partition_dynamic(all_render_calls) + dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references) + reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes) + reverse_graph = Hash.new { |hash, key| hash[key] = [] } + + render_graph.each do |file, partial_names| + next if file.start_with?("__") + + partial_names.each do |partial_name| + reverse_graph[partial_name] << file + end + end + + ruby_partial_references.each do |reference| + next unless reference.is_a?(String) + + reverse_graph[reference] << "__ruby__" + end + + basename = File.basename(file_path) + is_partial = basename.start_with?("_") + + puts "" + + if is_partial + partial_name = partial_name_for_file(file_path, view_root) + + unless partial_name + puts "Could not determine partial name for: #{file_path}" + return + end + + display = file_display_name(file_path, view_root) + status = reachable.include?(partial_name) ? green("\u2713") : yellow("~") + + puts " #{status} #{bold(partial_name)} #{dimmed(display)}" + puts "" + + callers = reverse_graph[partial_name] + + if callers.any? + puts " #{bold("Rendered by:")}" + + callers.each do |caller_file| + if caller_file == "__ruby__" + puts " \u251c\u2500\u2500 #{dimmed("[Ruby code]")}" + else + caller_display = file_display_name(caller_file, view_root) + caller_basename = File.basename(caller_file) + caller_status = caller_basename.start_with?("_") ? dimmed("(partial)") : dimmed("(entry point)") + puts " \u251c\u2500\u2500 #{cyan(caller_display)} #{caller_status}" + end + end + + puts "" + puts " #{bold("Reachable from:")}" + entry_chains = trace_to_entry_points(partial_name, reverse_graph, partial_files, view_root) + + if entry_chains.any? + entry_chains.each do |chain| + chain_display = chain.map { |name| cyan(name) }.join(dimmed(" \u2190 ")) + puts " \u251c\u2500\u2500 #{chain_display}" + end + else + puts " #{yellow("(not reachable from any entry point)")}" + end + else + puts " #{dimmed("Not rendered by any file.")}" + end + + children = render_graph[file_path] || [] + + if children.any? + puts "" + puts " #{bold("Renders:")}" + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + else + display = file_display_name(file_path, view_root) + puts " #{cyan(display)} #{dimmed("(entry point)")}" + + children = render_graph[file_path] || [] + + puts "" + if children.any? + puts " #{bold("Renders:")}" + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + else + puts " #{dimmed("No render calls in this file.")}" + end + end + + puts "" + end + + def graph! + erb_files = find_erb_files + view_root = find_view_root + + if erb_files.empty? + puts "No ERB files found." + return + end + + puts "" + puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" + puts "" + + if configuration.config_path + puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}" + else + puts dimmed("No .herb.yml found, using defaults") + end + + puts dimmed("Building render graph for #{erb_files.count} #{pluralize(erb_files.count, "file")}...") + + render_calls_by_file = collect_render_calls_by_file(erb_files) + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + all_render_calls = render_calls_by_file.values.flatten + _, dynamic_calls = partition_dynamic(all_render_calls) + dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references) + reverse_graph = Hash.new { |hash, key| hash[key] = [] } + + render_graph.each do |file, partial_names| + next if file.start_with?("__") + + display_name = file_display_name(file, view_root) + + partial_names.each do |partial_name| + reverse_graph[partial_name] << display_name + end + end + + ruby_partial_references.each do |reference| + next unless reference.is_a?(String) + + reverse_graph[reference] << "#{dimmed("[Ruby]")} #{reference}" + end + + entry_points = render_graph.keys.reject { |file| + file.start_with?("__") || File.basename(file).start_with?("_") + }.sort + + reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes) + + puts "" + puts separator + puts "" + + if entry_points.any? + puts " #{bold("Entry points:")} #{dimmed("(#{entry_points.count} #{pluralize(entry_points.count, "template")})")}" + + entry_points.each do |file| + display = file_display_name(file, view_root) + partials = render_graph[file] || [] + + puts "" + puts " #{cyan(display)}" + + if partials.empty? + puts " #{dimmed("(no render calls)")}" + else + print_partial_tree(partials, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + end + end + + ruby_static_references = ruby_partial_references.select { |reference| reference.is_a?(String) } + + if ruby_static_references.any? + puts "" + puts " #{separator}" + puts "" + puts " #{bold("Ruby references:")} #{dimmed("(#{ruby_static_references.count} #{pluralize(ruby_static_references.count, "partial")})")}" + + ruby_static_references.sort.each do |reference| + resolved = partial_files[reference] + status = resolved ? green("\u2713") : red("\u2717") + puts "" + puts " #{status} #{bold(reference)}" + + if resolved + children = render_graph[resolved] || [] + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + end + end + + unreachable = partial_files.except(*reachable) + + puts "" + puts " #{separator}" + puts "" + puts " #{bold("Partial usage:")} #{dimmed("(who renders each partial)")}" + + partial_files.keys.sort.each do |name| + callers = reverse_graph[name] + status = reachable.include?(name) ? green("\u2713") : yellow("~") + + puts "" + puts " #{status} #{bold(name)}" + + if callers.any? + callers.sort.each_with_index do |caller_name, index| + connector = index == callers.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" + puts " #{connector} #{dimmed("rendered by")} #{caller_name}" + end + else + puts " #{dimmed("(not rendered by any file)")}" + end + end + + if unreachable.any? + puts "" + puts " #{separator}" + puts "" + puts " #{bold(yellow("Unreachable partials:"))} #{dimmed("(#{unreachable.count} #{pluralize(unreachable.count, "file")})")}" + + unreachable.each do |name, file| + display = file_display_name(file, view_root) + children = render_graph[file] || [] + + puts "" + puts " #{yellow("~")} #{bold(name)} #{dimmed(display)}" + + if children.any? + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + end + end + + puts "" + puts " #{separator}" + puts "" + puts " #{bold("Summary:")}" + puts " #{label("Entry points")} #{cyan(entry_points.count.to_s)}" + puts " #{label("Partials")} #{cyan(partial_files.count.to_s)}" + puts " #{label("Reachable")} #{bold(green(reachable.count.to_s))}" + puts " #{label("Unreachable")} #{unreachable.any? ? bold(yellow(unreachable.count.to_s)) : bold(green("0"))}" + puts "" + end + + def analyze(erb_files = nil, view_root = nil) + erb_files ||= find_erb_files + view_root ||= find_view_root + + render_calls_by_file = collect_render_calls_by_file(erb_files) + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + + all_render_calls = render_calls_by_file.values.flatten + static_calls, dynamic_calls = partition_dynamic(all_render_calls) + + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + unresolved = find_unresolved(static_calls, partial_files, view_root) + + unused = find_unused_by_reachability( + render_graph, partial_files, ruby_partial_references, + collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references), + view_root + ) + + Result.new( + render_calls: all_render_calls, + dynamic_calls: dynamic_calls, + partial_files: partial_files, + unresolved: unresolved, + unused: unused, + view_root: view_root + ) + end + + def analyze_from_collected(render_calls_by_file:, dynamic_prefixes_from_erb: [], layout_refs_from_erb: []) + view_root = find_view_root + + @dynamic_prefixes_from_erb = dynamic_prefixes_from_erb + @layout_refs_from_erb = layout_refs_from_erb + + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + + all_render_calls = render_calls_by_file.values.flatten + static_calls, dynamic_calls = partition_dynamic(all_render_calls) + + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + unresolved = find_unresolved(static_calls, partial_files, view_root) + + unused = find_unused_by_reachability( + render_graph, partial_files, ruby_partial_references, + collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references), + view_root + ) + + Result.new( + render_calls: all_render_calls, + dynamic_calls: dynamic_calls, + partial_files: partial_files, + unresolved: unresolved, + unused: unused, + view_root: view_root + ) + end + + def print_file_lists(result) + return unless result.issues? + + if result.unresolved.any? + puts "\n" + puts " #{bold("Unresolved render calls:")}" + puts " #{dimmed("These render calls reference partials that could not be found on disk.")}" + + grouped = result.unresolved.group_by { |call| call[:file] } + + grouped.each do |file, calls| + relative = relative_path(file) + + puts "" + puts " #{cyan(relative)}:" + + calls.each do |call| + location = call[:location] ? dimmed("at #{call[:location]}") : nil + expected = expected_file_path(call[:partial], result.view_root) + puts " #{red("\u2717")} #{bold(call[:partial])} #{location} #{dimmed("-")} #{dimmed(expected)}" + end + end + end + + return unless result.unused.any? + + puts "\n #{separator}" if result.unresolved.any? + puts "\n" + puts " #{bold("Unused partials:")}" + puts " #{dimmed("These partial files are not referenced by any reachable render call.")}" + + result.unused.each do |name, file| + relative = relative_path(file) + + puts "" + puts " #{cyan(relative)}:" + puts " #{yellow("~")} #{bold(name)} #{dimmed("not referenced")}" + end + end + + def print_issue_summary(result) + return unless result.issues? + + if result.unresolved.any? + files_count = result.unresolved.map { |call| call[:file] }.uniq.count + + puts " #{white("Unresolved partials")} #{dimmed("(#{result.unresolved.count} #{pluralize(result.unresolved.count, "reference")} in #{files_count} #{pluralize(files_count, "file")})")}" + end + + return unless result.unused.any? + + puts " #{white("Unused partials")} #{dimmed("(#{result.unused.count} #{pluralize(result.unused.count, "file")})")}" + end + + def print_summary_line(result) + render_parts = [] + + partials_only = result.render_calls.count { |call| call[:partial] } + render_parts << stat(result.render_calls.count, "total", :green) + render_parts << stat(partials_only, "with partial", :green) + render_parts << stat(result.dynamic_calls.count, "dynamic", :yellow) if result.dynamic_calls.any? + other_count = result.render_calls.count - partials_only + render_parts << stat(other_count, "other", :green) if other_count.positive? + + partial_parts = [] + partial_parts << stat(result.partial_files.count, "on disk", :green) + partial_parts << stat(result.unresolved.count, "unresolved", :red) if result.unresolved.any? + partial_parts << stat(result.unused.count, "unused", :yellow) if result.unused.any? + + puts " #{label("Renders")} #{render_parts.join(" | ")}" + puts " #{label("Partials")} #{partial_parts.join(" | ")}" + end + + private + + def print_partial_tree(partial_names, render_graph, partial_files, view_root, reachable, indent: "", visited: Set.new) + partial_names.each_with_index do |name, index| + is_last = index == partial_names.size - 1 + connector = is_last ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" + child_indent = is_last ? " " : "\u2502 " + + resolved_file = partial_files[name] + status = if !resolved_file + red("\u2717") + elsif reachable.include?(name) + green("\u2713") + else + yellow("~") + end + + puts "#{indent}#{connector} #{status} #{name}" + + next unless resolved_file + next if visited.include?(name) + + visited.add(name) + + children = render_graph[resolved_file] || [] + + if children.any? + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: "#{indent}#{child_indent}", visited: visited) + end + end + end + + def file_display_name(file, view_root) + Pathname.new(file).relative_path_from(view_root).to_s + rescue ArgumentError + relative_path(file) + end + + def compute_reachable(render_graph, partial_files, ruby_references, dynamic_prefixes) + reachable = Set.new + queue = [] + + render_graph.each_key do |file| + next if file.start_with?("__") + + basename = File.basename(file) + next if basename.start_with?("_") + + queue << file + end + + ruby_references.each do |reference| + next unless reference.is_a?(String) + + reachable << reference + resolved_file = partial_files[reference] + queue << resolved_file if resolved_file + end + + (render_graph["__layout_refs__"] || []).each do |layout_name| + reachable << layout_name + resolved_file = partial_files[layout_name] + queue << resolved_file if resolved_file + end + + visited_files = Set.new + + until queue.empty? + current_file = queue.shift + next if visited_files.include?(current_file) + + visited_files << current_file + + partial_names = render_graph[current_file] || [] + + partial_names.each do |partial_name| + next if reachable.include?(partial_name) + + reachable << partial_name + + resolved_file = partial_files[partial_name] + queue << resolved_file if resolved_file && render_graph.key?(resolved_file) + end + end + + partial_files.each_key do |name| + if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") } + reachable << name + end + end + + reachable + end + + def trace_to_entry_points(partial_name, reverse_graph, _partial_files, view_root) + chains = [] + queue = [[partial_name]] + visited = Set.new([partial_name]) + + until queue.empty? + current_chain = queue.shift + + current_name = current_chain.last + callers = reverse_graph[current_name] || [] + + callers.each do |caller_file| + next if caller_file == "__ruby__" + + caller_basename = File.basename(caller_file) + + if caller_basename.start_with?("_") + caller_name = partial_name_for_file(caller_file, view_root) + next unless caller_name + next if visited.include?(caller_name) + + visited.add(caller_name) + queue << (current_chain + [caller_name]) + else + entry_display = file_display_name(caller_file, view_root) + chains << (current_chain + [entry_display]) + end + end + + if callers.include?("__ruby__") + chains << (current_chain + ["[Ruby code]"]) + end + end + + chains + end + + def print_results(result, duration) + if result.issues? + puts "" + puts separator + end + + print_file_lists(result) + + if result.issues? + puts "\n #{separator}" + puts "\n" + puts " #{bold("Issue summary:")}" + print_issue_summary(result) + end + + puts "\n #{separator}" + + scanned_files = result.render_calls.map { |call| call[:file] }.uniq.count + issues = result.unresolved.count + result.unused.count + + puts "\n" + puts " #{bold("Summary:")}" + + puts " #{label("Version")} #{cyan(Herb.version)}" + puts " #{label("Checked")} #{cyan("#{scanned_files} #{pluralize(scanned_files, "file")}")}" + + print_summary_line(result) + + puts " #{label("Duration")} #{cyan(format_duration(duration))}" + + if issues.zero? + puts "" + puts " #{bold(green("\u2713"))} #{green("All render calls resolve and all partials are used!")}" + end + + puts "" + end + + def find_erb_files + patterns = configuration.file_include_patterns + exclude = configuration.file_exclude_patterns + + files = patterns.flat_map { |pattern| Dir[File.join(@project_path, pattern)] }.uniq + + files.reject do |file| + relative = Pathname.new(file).relative_path_from(@project_path).to_s + exclude.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) } + end.sort + end + + def find_view_root + candidates = [ + @project_path.join("app", "views"), + @project_path + ] + + candidates.find(&:directory?) || @project_path + end + + def find_partial_files(view_root) + return {} unless view_root.directory? + + partials = {} + + Dir[File.join(view_root, "**", Herb::PARTIAL_GLOB_PATTERN)].each do |file| + partial_name = partial_name_for_file(file, view_root) + partials[partial_name] = file if partial_name + end + + partials + end + + def partial_name_for_file(file_path, view_root) + relative = Pathname.new(file_path).relative_path_from(view_root).to_s + + directory = File.dirname(relative) + basename = File.basename(relative) + + return nil unless basename.start_with?("_") + + name = basename.sub(/\A_/, "").sub(/\..*\z/, "") + + if directory == "." + name + else + "#{directory}/#{name}" + end + end + + def collect_render_calls_by_file(files) + @dynamic_prefixes_from_erb = [] + @layout_refs_from_erb = [] + + ensure_parallel! + + file_results = Parallel.map(files, in_processes: Parallel.processor_count) do |file| + process_file_for_render_calls(file) + end + + render_calls_by_file = {} + + file_results.each do |file_result| + next unless file_result + + render_calls_by_file[file_result[:file]] = file_result[:calls] + @dynamic_prefixes_from_erb.concat(file_result[:dynamic_prefixes]) + @layout_refs_from_erb.concat(file_result[:layout_references]) + end + + render_calls_by_file + end + + def process_file_for_render_calls(file) + content = File.read(file) + result = Herb.parse(content, render_nodes: true) + + visitor = RenderCallVisitor.new(file) + visitor.visit(result.value) + calls = visitor.render_calls.dup + + visitor_partials = calls.filter_map { |call| call[:partial] }.to_set + + dynamic_prefixes = [] + layout_references = [] + + content.scan(%r{render[\s(]+(?:partial:\s*)?["']([a-z0-9_/]+)["']}) do |match| + partial = match[0] + next if visitor_partials.include?(partial) + + calls << { file: file, partial: partial } + end + + content.scan(%r{render\s+(?:partial:\s*)?["']([a-z0-9_/]+)/\#\{}) do |match| + dynamic_prefixes << match[0] + end + + content.scan(%r{render\s+layout:\s*["']([a-z0-9_/]+)["']}) do |match| + layout_references << match[0] + end + + { file: file, calls: calls, dynamic_prefixes: dynamic_prefixes, layout_references: layout_references } + rescue StandardError => e + warn "Warning: Could not parse #{file}: #{e.message}" + nil + end + + def ensure_parallel! + return if defined?(Parallel) + + require "bundler/inline" + + gemfile(true, quiet: true) do + source "https://rubygems.org" + gem "parallel" + end + end + + def collect_ruby_render_references + references = [] + + ruby_directories = [ + @project_path.join("app"), + @project_path.join("lib") + ] + + ruby_directories.each do |directory| + next unless directory.directory? + + Dir[File.join(directory, "**", "*.rb")].each do |file| + content = File.read(file) + + content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)["']}) do |match| + references << match[0] + end + + content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)/\#\{}) do |match| + references << { prefix: match[0] } + end + rescue StandardError + next + end + end + + references + end + + def build_render_graph(render_calls_by_file, partial_files, view_root) + graph = {} + + render_calls_by_file.each do |file, calls| + resolved_names = [] + + calls.each do |call| + partial_reference = call[:partial] || call[:layout] + next unless partial_reference + + resolved = resolve_partial(partial_reference, file, partial_files, view_root) + if resolved + resolved_name = partial_name_for_file(resolved, view_root) + resolved_names << resolved_name if resolved_name + else + resolved_names << partial_reference + end + end + + graph[file] = resolved_names.uniq + end + + (@layout_refs_from_erb || []).each do |layout_reference| + graph["__layout_refs__"] ||= [] + graph["__layout_refs__"] << layout_reference + end + + graph + end + + def find_unused_by_reachability(render_graph, partial_files, ruby_references, dynamic_prefixes, _view_root) + reachable = Set.new + queue = [] + + render_graph.each_key do |file| + next if file.start_with?("__") + + basename = File.basename(file) + next if basename.start_with?("_") + + queue << file + end + + ruby_references.each do |reference| + next unless reference.is_a?(String) + + reachable << reference + resolved_file = partial_files[reference] + + queue << resolved_file if resolved_file + end + + (render_graph["__layout_refs__"] || []).each do |layout_name| + reachable << layout_name + resolved_file = partial_files[layout_name] + queue << resolved_file if resolved_file + end + + visited_files = Set.new + + until queue.empty? + current_file = queue.shift + next if visited_files.include?(current_file) + + visited_files << current_file + + partial_names = render_graph[current_file] || [] + + partial_names.each do |partial_name| + next if reachable.include?(partial_name) + + reachable << partial_name + + resolved_file = partial_files[partial_name] + queue << resolved_file if resolved_file && render_graph.key?(resolved_file) + end + end + + partial_files.each_key do |name| + if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") } + reachable << name + end + end + + partial_files.except(*reachable) + end + + def partition_dynamic(render_calls) + static = [] + dynamic = [] + + render_calls.each do |call| + if call[:partial] && dynamic_partial?(call[:partial]) + dynamic << call + else + static << call + end + end + + [static, dynamic] + end + + def dynamic_partial?(partial_name) + partial_name.include?("\#{") || partial_name.include?("#\{") || partial_name.match?(%r{[^a-z0-9_/]}) + end + + def collect_all_dynamic_prefixes(dynamic_calls, ruby_references) + prefixes = dynamic_calls.filter_map { |call| + next unless call[:partial] + + prefix = call[:partial].gsub(/\A["']|["']\z/, "") + prefix = prefix.split("\#{").first&.chomp("/") + prefix unless prefix.nil? || prefix.empty? + } + + ruby_references.each do |reference| + prefixes << reference[:prefix] if reference.is_a?(Hash) && reference[:prefix] + end + + prefixes.concat(@dynamic_prefixes_from_erb || []) + prefixes.uniq + end + + def find_unresolved(render_calls, partial_files, view_root) + render_calls.select do |call| + next false unless call[:partial] + + !resolve_partial(call[:partial], call[:file], partial_files, view_root) + end + end + + def resolve_partial(partial_name, source_file, partial_files, view_root) + return partial_files[partial_name] if partial_files.key?(partial_name) + + source_directory = begin + Pathname.new(File.dirname(source_file)).relative_path_from(view_root).to_s + rescue ArgumentError + nil + end + + if source_directory && source_directory != "." + relative_name = "#{source_directory}/#{partial_name}" + return partial_files[relative_name] if partial_files.key?(relative_name) + end + + unless partial_name.include?("/") + application_name = "application/#{partial_name}" + return partial_files[application_name] if partial_files.key?(application_name) + end + + nil + end + + def expected_file_path(partial_name, view_root) + parts = partial_name.split("/") + parts[-1] = "_#{parts[-1]}" + relative = parts.join("/") + + relative_root = relative_path(view_root.to_s) + + Herb::PARTIAL_EXTENSIONS.map { |extension| "#{relative_root}/#{relative}#{extension}" }.join(", ") + end + + def label(text, width = 12) + dimmed(text.ljust(width)) + end + + def stat(count, text, color) + value = "#{count} #{text}" + + if count.positive? + bold(send(color, value)) + else + bold(green(value)) + end + end + + def separator + dimmed("\u2500" * 60) + end + + def pluralize(count, singular, plural = nil) + count == 1 ? singular : (plural || "#{singular}s") + end + + def format_duration(seconds) + if seconds < 1 + "#{(seconds * 1000).round(2)}ms" + elsif seconds < 60 + "#{seconds.round(2)}s" + else + minutes = (seconds / 60).to_i + remaining_seconds = seconds % 60 + + "#{minutes}m #{remaining_seconds.round(2)}s" + end + end + + def relative_path(path) + Pathname.new(path).relative_path_from(Pathname.pwd).to_s + rescue ArgumentError + path.to_s + end + end + + class RenderCallVisitor < Visitor + attr_reader :render_calls + + def initialize(file) + @file = file + @render_calls = [] + end + + def visit_erb_render_node(node) + call = { file: @file } + + call[:partial] = node.partial_path if node.static_partial? + call[:template_path] = node.template_name if node.template_name + call[:layout] = node.layout_name if node.layout_name + call[:file_path] = node.file&.value if node.file + call[:inline] = true if node.inline_template + call[:renderable] = node.renderable&.value if node.renderable + call[:dynamic] = true if node.dynamic? + + [:body, :plain, :html].each do |field| + call[field] = true if node.send(field) + end + + if node.location + call[:location] = "#{node.location.start.line}:#{node.location.start.column}" + end + + @render_calls << call + + super + end + end +end diff --git a/test/engine/render_nodes_test.rb b/test/engine/render_nodes_test.rb new file mode 100644 index 000000000..c76af9865 --- /dev/null +++ b/test/engine/render_nodes_test.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require_relative "../snapshot_utils" +require_relative "../../lib/herb/engine" + +require "tmpdir" +require "fileutils" + +module Engine + class RenderNodesTest < Minitest::Spec + include SnapshotUtils + + def setup + @project_path = Dir.mktmpdir("herb_render_test") + @view_root = File.join(@project_path, "app", "views") + FileUtils.mkdir_p(File.join(@view_root, "posts")) + FileUtils.mkdir_p(File.join(@view_root, "shared")) + + File.write(File.join(@view_root, "shared", "_header.html.erb"), "
Header
") + File.write(File.join(@view_root, "shared", "_footer.html.erb"), "") + File.write(File.join(@view_root, "posts", "_card.html.erb"), "
Card
") + end + + def teardown + FileUtils.rm_rf(@project_path) + end + + def compile(template, **options) + Herb::Engine.new( + template, + filename: "app/views/posts/show.html.erb", + project_path: @project_path, + framework: :action_view, + **options + ) + end + + def render_diagnostics(template, **_options) + result = Herb.parse(template, render_nodes: true) + + validator = Herb::Engine::Validators::RenderValidator.new( + enabled: true, + filename: Pathname.new("app/views/posts/show.html.erb"), + project_path: Pathname.new(@project_path) + ) + + result.value.accept(validator) + + validator.diagnostics + end + + test "no diagnostics for existing partial" do + diagnostics = render_diagnostics('<%= render "shared/header" %>') + + assert_empty diagnostics + end + + test "no diagnostics for existing relative partial" do + diagnostics = render_diagnostics('<%= render "card" %>') + + assert_empty diagnostics + end + + test "warns when partial cannot be resolved" do + diagnostics = render_diagnostics('<%= render "nonexistent/missing" %>') + + assert_equal 1, diagnostics.length + assert_equal :error, diagnostics.first[:severity] + assert_includes diagnostics.first[:message], "Partial 'nonexistent/missing' could not be resolved" + assert_equal "RenderUnresolved", diagnostics.first[:code] + end + + test "warns for dynamic render calls" do + diagnostics = render_diagnostics("<%= render @product %>") + + assert_equal 1, diagnostics.length + assert_equal :warning, diagnostics.first[:severity] + assert_includes diagnostics.first[:message], "Dynamic render call cannot be statically resolved" + assert_equal "RenderDynamic", diagnostics.first[:code] + end + + test "no diagnostics for keyword partial that exists" do + diagnostics = render_diagnostics('<%= render partial: "shared/header" %>') + + assert_empty diagnostics + end + + test "warns for keyword partial that does not exist" do + diagnostics = render_diagnostics('<%= render partial: "missing/partial" %>') + + assert_equal 1, diagnostics.length + assert_includes diagnostics.first[:message], "Partial 'missing/partial' could not be resolved" + end + + test "validator is disabled without action_view framework" do + engine = Herb::Engine.new( + '<%= render "nonexistent" %>', + filename: "app/views/posts/show.html.erb", + project_path: @project_path + ) + + refute_nil engine.src + end + + test "no validation when filename is not provided" do + result = Herb.parse('<%= render "nonexistent" %>', render_nodes: true) + + validator = Herb::Engine::Validators::RenderValidator.new( + enabled: true, + filename: nil, + project_path: Pathname.new(@project_path) + ) + + result.value.accept(validator) + + assert_empty validator.diagnostics + end + + test "compiled output is identical with or without framework" do + template = '<%= render "shared/header" %>' + + without = Herb::Engine.new(template) + with = compile(template) + + assert_equal without.src, with.src + end + end +end diff --git a/test/render_checker_test.rb b/test/render_checker_test.rb new file mode 100644 index 000000000..bb843c95b --- /dev/null +++ b/test/render_checker_test.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require_relative "../lib/herb/render_checker" +require "tmpdir" +require "fileutils" + +class RenderCheckerTest < Minitest::Spec + def create_project(files = {}) + directory = Dir.mktmpdir("herb-render-check-") + + files.each do |path, content| + full_path = File.join(directory, path) + FileUtils.mkdir_p(File.dirname(full_path)) + File.write(full_path, content) + end + + directory + end + + def check(directory) + checker = Herb::RenderChecker.new(directory) + checker.analyze + end + + def teardown + @directories_to_clean&.each { |directory| FileUtils.rm_rf(directory) } + end + + def track_directory(directory) + @directories_to_clean ||= [] + @directories_to_clean << directory + directory + end + + test "no issues when all render calls resolve" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/shared/_header.html.erb" => "

Header

" + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + refute result.issues? + end + + test "detects unresolved render call" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/missing" %>' + )) + + result = check(directory) + + assert_equal 1, result.unresolved.count + assert_equal "shared/missing", result.unresolved.first[:partial] + end + + test "detects unused partial" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => "

Hello

", + "app/views/shared/_orphan.html.erb" => "

Orphan

" + )) + + result = check(directory) + + assert_equal 1, result.unused.count + assert_includes result.unused.keys, "shared/orphan" + end + + test "partial rendered from another partial is reachable" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => "

Leaf

" + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "deep transitive chain is fully reachable" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => '<%= render "shared/c" %>', + "app/views/shared/_c.html.erb" => '<%= render "shared/d" %>', + "app/views/shared/_d.html.erb" => "

Deep leaf

" + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "unreachable chain is entirely unused" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => "

No renders

", + "app/views/shared/_orphan.html.erb" => '<%= render "shared/orphan_child" %>', + "app/views/shared/_orphan_child.html.erb" => "

Child

" + )) + + result = check(directory) + + assert_equal 2, result.unused.count + assert_includes result.unused.keys, "shared/orphan" + assert_includes result.unused.keys, "shared/orphan_child" + end + + test "partial only rendered from unreachable partial is unused" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/used" %>', + "app/views/shared/_used.html.erb" => "

Used

", + "app/views/shared/_unused_parent.html.erb" => '<%= render "shared/unused_child" %>', + "app/views/shared/_unused_child.html.erb" => "

Child

" + )) + + result = check(directory) + + assert_equal 2, result.unused.count + assert_includes result.unused.keys, "shared/unused_parent" + assert_includes result.unused.keys, "shared/unused_child" + refute_includes result.unused.keys, "shared/used" + end + + test "multiple entry points can reach different partials" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/pages/about.html.erb" => '<%= render "shared/footer" %>', + "app/views/shared/_header.html.erb" => "

Header

", + "app/views/shared/_footer.html.erb" => "" + )) + + result = check(directory) + + assert_empty result.unused + end + + test "partial reachable from one entry point but not another is still used" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/sidebar" %>', + "app/views/pages/about.html.erb" => "

No sidebar here

", + "app/views/shared/_sidebar.html.erb" => "" + )) + + result = check(directory) + + assert_empty result.unused + end + + test "resolves short partial name relative to calling file directory" do + directory = track_directory(create_project( + "app/views/posts/index.html.erb" => '<%= render "card" %>', + "app/views/posts/_card.html.erb" => "
Card
" + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "resolves application/ fallback for short partial names" do + directory = track_directory(create_project( + "app/views/posts/index.html.erb" => '<%= render "toolbar" %>', + "app/views/application/_toolbar.html.erb" => "" + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "layout render with block is detected via regex fallback" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render layout: "shared/wrapper" do %>Content<% end %>', + "app/views/shared/_wrapper.html.erb" => "
<%= yield %>
" + )) + + result = check(directory) + + assert_empty result.unused + end + + test "dynamic prefix marks matching partials as reachable" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "providers/#{provider_name}" %>', + "app/views/providers/_youtube.html.erb" => "
YouTube
", + "app/views/providers/_vimeo.html.erb" => "
Vimeo
" + )) + + result = check(directory) + + assert_empty result.unused + end + + test "dynamic render does not report unresolved" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "providers/#{name}" %>' + )) + + result = check(directory) + + assert_empty result.unresolved + end + + test "render call from Ruby controller marks partial as reachable" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => "

Page

", + "app/views/shared/_from_controller.html.erb" => "

From controller

", + "app/controllers/pages_controller.rb" => 'render partial: "shared/from_controller"' + )) + + result = check(directory) + + refute_includes result.unused.keys, "shared/from_controller" + end + + test "dynamic render from Ruby controller marks prefix partials as reachable" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => "

Page

", + "app/views/cards/_basic.html.erb" => "
Basic
", + "app/views/cards/_premium.html.erb" => "
Premium
", + "app/controllers/pages_controller.rb" => 'render partial: "cards/#{card_type}"' + )) + + result = check(directory) + + refute_includes result.unused.keys, "cards/basic" + refute_includes result.unused.keys, "cards/premium" + end + + test "render in non-output ERB tag is detected via regex fallback" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<% content = render("shared/widget") %><%= content %>', + "app/views/shared/_widget.html.erb" => "
Widget
" + )) + + result = check(directory) + + assert_empty result.unused + end + + test "circular render references do not cause infinite loop" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => '<%= render "shared/a" %>' + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "empty project has no issues" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => "

Hello

" + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "mixed resolved, unresolved, and unused" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %><%= render "shared/missing" %>', + "app/views/shared/_header.html.erb" => "

Header

", + "app/views/shared/_unused.html.erb" => "

Never used

" + )) + + result = check(directory) + + assert_equal 1, result.unresolved.count + assert_equal "shared/missing", result.unresolved.first[:partial] + assert_equal 1, result.unused.count + assert_includes result.unused.keys, "shared/unused" + end + + test "resolves partials with various extensions" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %><%= render "shared/b" %>', + "app/views/shared/_a.html.erb" => "

ERB

", + "app/views/shared/_b.turbo_stream.erb" => "B" + )) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "result tracks render call counts" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %><%= render "shared/b" %>', + "app/views/shared/_a.html.erb" => "

A

", + "app/views/shared/_b.html.erb" => "

B

" + )) + + result = check(directory) + + assert_equal 2, result.render_calls.count + assert_equal 2, result.partial_files.count + end + + test "result issues? returns false when clean" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/shared/_header.html.erb" => "

Header

" + )) + + result = check(directory) + + refute result.issues? + end + + test "result issues? returns true with unresolved" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/missing" %>' + )) + + result = check(directory) + + assert result.issues? + end + + test "result issues? returns true with unused" do + directory = track_directory(create_project( + "app/views/pages/index.html.erb" => "

Hello

", + "app/views/shared/_orphan.html.erb" => "

Orphan

" + )) + + result = check(directory) + + assert result.issues? + end +end From fa509f65a227f1663d58c08db10839e2181c0e24 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 21 Apr 2026 09:44:08 +0900 Subject: [PATCH 2/2] Cleanup --- .rubocop.yml | 7 +- lib/herb/action_view/render_analyzer.rb | 1062 +++++++++++++++++ lib/herb/ast/erb_render_node.rb | 18 +- lib/herb/cli.rb | 49 +- lib/herb/render_checker.rb | 1008 ---------------- sig/herb.rbs | 6 + sig/herb/action_view/render_analyzer.rbs | 122 ++ sig/herb/ast/erb_render_node.rbs | 29 + .../engine/validators/render_validator.rbs | 21 + sig/herb_c_extension.rbs | 2 +- sig/vendor/did_you_mean.rbs | 6 + sig/vendor/parallel.rbs | 4 + sorbet/rbi/parallel.rbi | 6 + test/engine/render_nodes_test.rb | 37 +- test/render_analyzer_test.rb | 428 +++++++ test/render_checker_test.rb | 345 ------ 16 files changed, 1763 insertions(+), 1387 deletions(-) create mode 100644 lib/herb/action_view/render_analyzer.rb delete mode 100644 lib/herb/render_checker.rb create mode 100644 sig/herb/action_view/render_analyzer.rbs create mode 100644 sig/herb/ast/erb_render_node.rbs create mode 100644 sig/herb/engine/validators/render_validator.rbs create mode 100644 sig/vendor/did_you_mean.rbs create mode 100644 sig/vendor/parallel.rbs create mode 100644 sorbet/rbi/parallel.rbi create mode 100644 test/render_analyzer_test.rb delete mode 100644 test/render_checker_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 73b493bec..90423f3f3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -42,6 +42,7 @@ Style/BlockDelimiters: Style/SafeNavigationChainLength: Exclude: + - lib/herb/ast/erb_render_node.rb - lib/herb/dev/runner.rb Style/GlobalVars: @@ -74,6 +75,7 @@ Metrics/MethodLength: Exclude: - bench/**/* - bin/**/* + - lib/herb/ast/erb_render_node.rb - lib/herb/ast/nodes.rb - lib/herb/cli.rb - lib/herb/dev/runner.rb @@ -90,11 +92,12 @@ Metrics/AbcSize: Exclude: - bench/**/* - bin/**/* + - lib/herb/ast/erb_render_node.rb - lib/herb/ast/node.rb - lib/herb/ast/nodes.rb - lib/herb/cli.rb - - lib/herb/dev/server.rb - lib/herb/dev/runner.rb + - lib/herb/dev/server.rb - lib/herb/engine.rb - lib/herb/engine/**/*.rb - lib/herb/errors.rb @@ -110,6 +113,7 @@ Metrics/AbcSize: Metrics/ClassLength: Exclude: - bin/**/* + - lib/herb/ast/erb_render_node.rb - lib/herb/ast/nodes.rb - lib/herb/cli.rb - lib/herb/configuration.rb @@ -155,6 +159,7 @@ Metrics/PerceivedComplexity: Exclude: - bench/**/* - bin/**/* + - lib/herb/ast/erb_render_node.rb - lib/herb/ast/nodes.rb - lib/herb/cli.rb - lib/herb/dev/runner.rb diff --git a/lib/herb/action_view/render_analyzer.rb b/lib/herb/action_view/render_analyzer.rb new file mode 100644 index 000000000..b4f183d32 --- /dev/null +++ b/lib/herb/action_view/render_analyzer.rb @@ -0,0 +1,1062 @@ +# frozen_string_literal: true +# typed: false + +require "pathname" + +module Herb + module ActionView + class RenderAnalyzer + include Colors + + Result = Data.define(:render_calls, :dynamic_calls, :partial_files, :unresolved, :unused, :view_root) + + class Result + def issues? + unresolved.any? || unused.any? + end + end + + attr_reader :project_path, :configuration + + def initialize(project_path, configuration: nil) + @project_path = Pathname.new(File.expand_path(project_path)) + @configuration = configuration || Configuration.load(@project_path.to_s) + end + + def check! + start_time = Time.now + + erb_files = find_erb_files + view_root = find_view_root + + if erb_files.empty? + puts "No ERB files found." + return false + end + + puts "" + puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" + puts "" + + if configuration.config_path + puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}" + else + puts dimmed("No .herb.yml found, using defaults") + end + + puts dimmed("Checking render calls in #{erb_files.count} #{pluralize(erb_files.count, "file")}...") + + result = analyze(erb_files, view_root) + duration = Time.now - start_time + + print_results(result, duration) + + result.issues? + end + + def fully_resolvable?(file_path) + file_path = @project_path.join(file_path).to_s unless Pathname.new(file_path).absolute? + + erb_files = find_erb_files + view_root = find_view_root + + render_calls_by_file = collect_render_calls_by_file(erb_files) + partial_files = find_partial_files(view_root) + + visited = Set.new + queue = [file_path] + + while (current = queue.shift) + next if visited.include?(current) + + visited << current + + calls = render_calls_by_file[current] || [] + + calls.each do |call| + return false if call[:dynamic] + + partial_ref = call[:partial] || call[:layout] + next unless partial_ref + + return false if dynamic_partial?(partial_ref) + + resolved = resolve_partial(partial_ref, current, partial_files, view_root) + return false unless resolved + + queue << resolved + end + end + + true + end + + def graph_file!(file_path) + is_partial = File.basename(file_path).start_with?("_") + + puts "" + puts " #{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" + puts "" + puts " #{dimmed("Building render graph...")}" + + erb_files = find_erb_files + view_root = find_view_root + + if erb_files.empty? + puts "No ERB files found in project." + return + end + + render_calls_by_file = collect_render_calls_by_file(erb_files) + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + all_render_calls = render_calls_by_file.values.flatten + _, dynamic_calls = partition_dynamic(all_render_calls) + dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references) + reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes) + reverse_graph = Hash.new { |hash, key| hash[key] = [] } #: Hash[String, Array[String]] + + render_graph.each do |file, partial_names| + next if file.start_with?("__") + + partial_names.each do |partial_name| + reverse_graph[partial_name] << file + end + end + + ruby_partial_references.each do |reference| + next unless reference.is_a?(String) + + reverse_graph[reference] << "__ruby__" + end + + puts "" + + if is_partial + partial_name = partial_name_for_file(file_path, view_root) + + unless partial_name + puts "Could not determine partial name for: #{file_path}" + return + end + + display = file_display_name(file_path, view_root) + status = reachable.include?(partial_name) ? green("\u2713") : yellow("~") + + puts " #{status} #{bold(partial_name)} #{dimmed(display)}" + puts "" + + callers = reverse_graph[partial_name] + + if callers.any? + puts " #{bold("Rendered by:")}" + + callers.each_with_index do |caller_file, index| + connector = index == callers.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" + + if caller_file == "__ruby__" + puts " #{connector} #{dimmed("[Ruby code]")}" + else + caller_display = file_display_name(caller_file, view_root) + caller_basename = File.basename(caller_file) + caller_status = caller_basename.start_with?("_") ? dimmed("(partial)") : dimmed("(entry point)") + puts " #{connector} #{cyan(caller_display)} #{caller_status}" + end + end + + puts "" + puts " #{bold("Reachable from:")}" + entry_chains = trace_to_entry_points(partial_name, reverse_graph, partial_files, view_root) + + if entry_chains.any? + entry_chains.each_with_index do |chain, index| + connector = index == entry_chains.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" + reversed = chain.reverse + + chain_display = reversed.each_with_index.map do |name, i| + if i == 0 + bold(green(name)) + elsif i == reversed.size - 1 + bold(name) + else + dimmed(name) + end + end.join(dimmed(" \u2192 ")) + puts " #{connector} #{chain_display}" + end + else + puts " #{yellow("(not reachable from any entry point)")}" + end + else + puts " #{dimmed("Not rendered by any file.")}" + end + + children = render_graph[file_path] || [] + + if children.any? + puts "" + puts " #{bold("Renders:")}" + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + else + display = file_display_name(file_path, view_root) + puts " #{cyan(display)} #{dimmed("(entry point)")}" + puts "" + + children = render_graph[file_path] || [] + + if children.any? + puts " #{bold("Renders:")}" + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + else + puts " #{dimmed("No render calls in this file.")}" + end + end + + puts "" + end + + def graph! + erb_files = find_erb_files + view_root = find_view_root + + if erb_files.empty? + puts "No ERB files found." + return + end + + puts "" + puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" + puts "" + + if configuration.config_path + puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}" + else + puts dimmed("No .herb.yml found, using defaults") + end + + puts dimmed("Building render graph for #{erb_files.count} #{pluralize(erb_files.count, "file")}...") + + render_calls_by_file = collect_render_calls_by_file(erb_files) + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + all_render_calls = render_calls_by_file.values.flatten + _, dynamic_calls = partition_dynamic(all_render_calls) + dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references) + reverse_graph = Hash.new { |hash, key| hash[key] = [] } #: Hash[String, Array[String]] + + render_graph.each do |file, partial_names| + next if file.start_with?("__") + + display_name = file_display_name(file, view_root) + + partial_names.each do |partial_name| + reverse_graph[partial_name] << display_name + end + end + + ruby_partial_references.each do |reference| + next unless reference.is_a?(String) + + reverse_graph[reference] << "#{dimmed("[Ruby]")} #{reference}" + end + + entry_points = render_graph.keys.reject { |file| + file.start_with?("__") || File.basename(file).start_with?("_") + }.sort + + reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes) + + puts "" + puts separator + puts "" + + if entry_points.any? + puts " #{bold("Entry points:")} #{dimmed("(#{entry_points.count} #{pluralize(entry_points.count, "template")})")}" + + entry_points.each do |file| + display = file_display_name(file, view_root) + partials = render_graph[file] || [] + + puts "" + puts " #{cyan(display)}" + + if partials.empty? + puts " #{dimmed("(no render calls)")}" + else + print_partial_tree(partials, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + end + end + + ruby_static_references = ruby_partial_references.select { |reference| reference.is_a?(String) } + + if ruby_static_references.any? + puts "" + puts " #{separator}" + puts "" + puts " #{bold("Ruby references:")} #{dimmed("(#{ruby_static_references.count} #{pluralize(ruby_static_references.count, "partial")})")}" + + ruby_static_references.sort.each do |reference| + resolved = partial_files[reference] + status = resolved ? green("\u2713") : red("\u2717") + puts "" + puts " #{status} #{bold(reference)}" + + if resolved + children = render_graph[resolved] || [] + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + end + end + + unreachable = partial_files.except(*reachable) + + puts "" + puts " #{separator}" + puts "" + puts " #{bold("Partial usage:")} #{dimmed("(who renders each partial)")}" + + partial_files.keys.sort.each do |name| + callers = reverse_graph[name] + status = reachable.include?(name) ? green("\u2713") : yellow("~") + + puts "" + puts " #{status} #{bold(name)}" + + if callers.any? + callers.sort.each_with_index do |caller_name, index| + connector = index == callers.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" + puts " #{connector} #{dimmed("rendered by")} #{caller_name}" + end + else + puts " #{dimmed("(not rendered by any file)")}" + end + end + + if unreachable.any? + puts "" + puts " #{separator}" + puts "" + puts " #{bold(yellow("Unreachable partials:"))} #{dimmed("(#{unreachable.count} #{pluralize(unreachable.count, "file")})")}" + + unreachable.each do |name, file| + display = file_display_name(file, view_root) + children = render_graph[file] || [] + + puts "" + puts " #{yellow("~")} #{bold(name)} #{dimmed(display)}" + + if children.any? + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) + end + end + end + + puts "" + puts " #{separator}" + puts "" + puts " #{bold("Summary:")}" + puts " #{label("Entry points")} #{cyan(entry_points.count.to_s)}" + puts " #{label("Partials")} #{cyan(partial_files.count.to_s)}" + puts " #{label("Reachable")} #{bold(green(reachable.count.to_s))}" + puts " #{label("Unreachable")} #{unreachable.any? ? bold(yellow(unreachable.count.to_s)) : bold(green("0"))}" + puts "" + end + + def analyze(erb_files = nil, view_root = nil) + erb_files ||= find_erb_files + view_root ||= find_view_root + + render_calls_by_file = collect_render_calls_by_file(erb_files) + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + + all_render_calls = render_calls_by_file.values.flatten + static_calls, dynamic_calls = partition_dynamic(all_render_calls) + + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + unresolved = find_unresolved(static_calls, partial_files, view_root) + + unused = find_unused_by_reachability( + render_graph, partial_files, ruby_partial_references, + collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references), + view_root + ) + + Result.new( + render_calls: all_render_calls, + dynamic_calls: dynamic_calls, + partial_files: partial_files, + unresolved: unresolved, + unused: unused, + view_root: view_root + ) + end + + def analyze_from_collected(render_calls_by_file:, dynamic_prefixes_from_erb: [], layout_refs_from_erb: []) + view_root = find_view_root + + @dynamic_prefixes_from_erb = dynamic_prefixes_from_erb + @layout_refs_from_erb = layout_refs_from_erb + + ruby_partial_references = collect_ruby_render_references + partial_files = find_partial_files(view_root) + + all_render_calls = render_calls_by_file.values.flatten + static_calls, dynamic_calls = partition_dynamic(all_render_calls) + + render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) + + unresolved = find_unresolved(static_calls, partial_files, view_root) + + unused = find_unused_by_reachability( + render_graph, partial_files, ruby_partial_references, + collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references), + view_root + ) + + Result.new( + render_calls: all_render_calls, + dynamic_calls: dynamic_calls, + partial_files: partial_files, + unresolved: unresolved, + unused: unused, + view_root: view_root + ) + end + + def print_file_lists(result) + return unless result.issues? + + if result.unresolved.any? + puts "\n" + puts " #{bold("Unresolved render calls:")}" + puts " #{dimmed("These render calls reference partials that could not be found on disk.")}" + + grouped = result.unresolved.group_by { |call| call[:file] } + + grouped.each do |file, calls| + relative = relative_path(file) + + puts "" + puts " #{cyan(relative)}:" + + calls.each do |call| + location = call[:location] ? dimmed("at #{call[:location]}") : nil + expected = expected_file_path(call[:partial], result.view_root) + puts " #{red("\u2717")} #{bold(call[:partial])} #{location} #{dimmed("-")} #{dimmed(expected)}" + end + end + end + + return unless result.unused.any? + + puts "\n #{separator}" if result.unresolved.any? + puts "\n" + puts " #{bold("Unused partials:")}" + puts " #{dimmed("These partial files are not referenced by any reachable render call.")}" + + result.unused.each do |name, file| + relative = relative_path(file) + + puts "" + puts " #{cyan(relative)}:" + puts " #{yellow("~")} #{bold(name)} #{dimmed("not referenced")}" + end + end + + def print_issue_summary(result) + return unless result.issues? + + if result.unresolved.any? + files_count = result.unresolved.map { |call| call[:file] }.uniq.count + + puts " #{white("Unresolved partials")} #{dimmed("(#{result.unresolved.count} #{pluralize(result.unresolved.count, "reference")} in #{files_count} #{pluralize(files_count, "file")})")}" + end + + return unless result.unused.any? + + puts " #{white("Unused partials")} #{dimmed("(#{result.unused.count} #{pluralize(result.unused.count, "file")})")}" + end + + def print_summary_line(result) + render_parts = [] #: Array[String] + + partials_only = result.render_calls.count { |call| call[:partial] } + render_parts << stat(result.render_calls.count, "total", :green) + render_parts << stat(partials_only, "with partial", :green) + render_parts << stat(result.dynamic_calls.count, "dynamic", :yellow) if result.dynamic_calls.any? + other_count = result.render_calls.count - partials_only + render_parts << stat(other_count, "other", :green) if other_count.positive? + + partial_parts = [] #: Array[String] + partial_parts << stat(result.partial_files.count, "on disk", :green) + partial_parts << stat(result.unresolved.count, "unresolved", :red) if result.unresolved.any? + partial_parts << stat(result.unused.count, "unused", :yellow) if result.unused.any? + + puts " #{label("Renders")} #{render_parts.join(" | ")}" + puts " #{label("Partials")} #{partial_parts.join(" | ")}" + end + + private + + def print_partial_tree(partial_names, render_graph, partial_files, view_root, reachable, indent: "", visited: Set.new) + partial_names.each_with_index do |name, index| + is_last = index == partial_names.size - 1 + connector = is_last ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" + child_indent = is_last ? " " : "\u2502 " + + resolved_file = partial_files[name] + status = if !resolved_file + red("\u2717") + elsif reachable.include?(name) + green("\u2713") + else + yellow("~") + end + + puts "#{indent}#{connector} #{status} #{name}" + + next unless resolved_file + next if visited.include?(name) + + visited.add(name) + + children = render_graph[resolved_file] || [] + + if children.any? + print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: "#{indent}#{child_indent}", visited: visited) + end + end + end + + def file_display_name(file, view_root) + Pathname.new(file).relative_path_from(view_root).to_s + rescue ArgumentError + relative_path(file) + end + + def compute_reachable(render_graph, partial_files, ruby_references, dynamic_prefixes) + reachable = Set.new + queue = [] #: Array[String] + + render_graph.each_key do |file| + next if file.start_with?("__") + + basename = File.basename(file) + next if basename.start_with?("_") + + queue << file + end + + ruby_references.each do |reference| + next unless reference.is_a?(String) + + reachable << reference + resolved_file = partial_files[reference] + queue << resolved_file if resolved_file + end + + (render_graph["__layout_refs__"] || []).each do |layout_name| + reachable << layout_name + resolved_file = partial_files[layout_name] + queue << resolved_file if resolved_file + end + + visited_files = Set.new + + until queue.empty? + current_file = queue.shift + next if visited_files.include?(current_file) + + visited_files << current_file + + partial_names = render_graph[current_file] || [] + + partial_names.each do |partial_name| + next if reachable.include?(partial_name) + + reachable << partial_name + + resolved_file = partial_files[partial_name] + queue << resolved_file if resolved_file && render_graph.key?(resolved_file) + end + end + + partial_files.each_key do |name| + if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") } + reachable << name + end + end + + reachable + end + + def trace_to_entry_points(partial_name, reverse_graph, _partial_files, view_root) + chains = [] #: Array[Array[String]] + queue = [[partial_name]] + visited = Set.new([partial_name]) + + until queue.empty? + current_chain = queue.shift + + current_name = current_chain.last + callers = reverse_graph[current_name] || [] + + callers.each do |caller_file| + next if caller_file == "__ruby__" + + caller_basename = File.basename(caller_file) + + if caller_basename.start_with?("_") + caller_name = partial_name_for_file(caller_file, view_root) + next unless caller_name + next if visited.include?(caller_name) + + visited.add(caller_name) + queue << (current_chain + [caller_name]) + else + entry_display = file_display_name(caller_file, view_root) + chains << (current_chain + [entry_display]) + end + end + + if callers.include?("__ruby__") + chains << (current_chain + ["[Ruby code]"]) + end + end + + chains + end + + def print_results(result, duration) + if result.issues? + puts "" + puts separator + end + + print_file_lists(result) + + if result.issues? + puts "\n #{separator}" + puts "\n" + puts " #{bold("Issue summary:")}" + print_issue_summary(result) + end + + puts "\n #{separator}" + + scanned_files = result.render_calls.map { |call| call[:file] }.uniq.count + issues = result.unresolved.count + result.unused.count + + puts "\n" + puts " #{bold("Summary:")}" + + puts " #{label("Version")} #{cyan(Herb.version)}" + puts " #{label("Checked")} #{cyan("#{scanned_files} #{pluralize(scanned_files, "file")}")}" + + print_summary_line(result) + + puts " #{label("Duration")} #{cyan(format_duration(duration))}" + + if issues.zero? + puts "" + puts " #{bold(green("\u2713"))} #{green("All render calls resolve and all partials are used!")}" + end + + puts "" + end + + def find_erb_files + patterns = configuration.file_include_patterns + exclude = configuration.file_exclude_patterns + + files = patterns.flat_map { |pattern| Dir[File.join(@project_path, pattern)] }.uniq + + files.reject do |file| + relative = Pathname.new(file).relative_path_from(@project_path).to_s + exclude.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) } + end.sort + end + + def find_view_root + candidates = [ + @project_path.join("app", "views"), + @project_path + ] + + candidates.find(&:directory?) || @project_path + end + + def find_partial_files(view_root) + return {} unless view_root.directory? + + partials = {} #: Hash[String, String] + + Dir[File.join(view_root, "**", Herb::PARTIAL_GLOB_PATTERN)].each do |file| + partial_name = partial_name_for_file(file, view_root) + partials[partial_name] = file if partial_name + end + + partials + end + + def partial_name_for_file(file_path, view_root) + relative = Pathname.new(file_path).relative_path_from(view_root).to_s + + directory = File.dirname(relative) + basename = File.basename(relative) + + return nil unless basename.start_with?("_") + + name = basename.sub(/\A_/, "").sub(/\..*\z/, "") + + if directory == "." + name + else + "#{directory}/#{name}" + end + end + + def collect_render_calls_by_file(files) + @dynamic_prefixes_from_erb = [] #: Array[String] + @layout_refs_from_erb = [] #: Array[String] + + ensure_parallel! + + file_results = Parallel.map(files, in_processes: Parallel.processor_count) do |file| + process_file_for_render_calls(file) + end + + render_calls_by_file = {} #: Hash[String, Array[Hash[Symbol, untyped]]] + + file_results.each do |file_result| + next unless file_result + + render_calls_by_file[file_result[:file]] = file_result[:calls] + @dynamic_prefixes_from_erb.concat(file_result[:dynamic_prefixes]) + @layout_refs_from_erb.concat(file_result[:layout_references]) + end + + render_calls_by_file + end + + def process_file_for_render_calls(file) + content = File.read(file) + result = Herb.parse(content, render_nodes: true) + + visitor = RenderCallVisitor.new(file) + visitor.visit(result.value) + calls = visitor.render_calls.dup + + visitor_partials = calls.filter_map { |call| call[:partial] }.to_set + + dynamic_prefixes = [] #: Array[untyped] + layout_references = [] #: Array[untyped] + + content.scan(%r{render[\s(]+(?:partial:\s*)?["']([a-z0-9_/]+)["']}) do |match| + partial = match[0] + next if visitor_partials.include?(partial) + + calls << { file: file, partial: partial } + end + + content.scan(%r{render\s+(?:partial:\s*)?["']([a-z0-9_/]+)/\#\{}) do |match| + dynamic_prefixes << match[0] + end + + content.scan(%r{render\s+layout:\s*["']([a-z0-9_/]+)["']}) do |match| + layout_references << match[0] + end + + { file: file, calls: calls, dynamic_prefixes: dynamic_prefixes, layout_references: layout_references } + rescue StandardError => e + warn "Warning: Could not parse #{file}: #{e.message}" + nil + end + + def ensure_parallel! + return if defined?(Parallel) + + require "bundler/inline" + + gemfile(true, quiet: true) do # steep:ignore + source "https://rubygems.org" # steep:ignore + gem "parallel" # steep:ignore + end + end + + def collect_ruby_render_references + references = [] #: Array[untyped] + + ruby_directories = [ + @project_path.join("app"), + @project_path.join("lib") + ] + + ruby_directories.each do |directory| + next unless directory.directory? + + Dir[File.join(directory, "**", "*.rb")].each do |file| + content = File.read(file) + + content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)["']}) do |match| + references << match[0] + end + + content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)/\#\{}) do |match| + references << { prefix: match[0] } + end + rescue StandardError + next + end + end + + references + end + + def build_render_graph(render_calls_by_file, partial_files, view_root) + graph = {} #: Hash[String, Array[String]] + + render_calls_by_file.each do |file, calls| + resolved_names = [] #: Array[String] + + calls.each do |call| + partial_reference = call[:partial] || call[:layout] + next unless partial_reference + + resolved = resolve_partial(partial_reference, file, partial_files, view_root) + if resolved + resolved_name = partial_name_for_file(resolved, view_root) + resolved_names << resolved_name if resolved_name + else + resolved_names << partial_reference + end + end + + graph[file] = resolved_names.uniq + end + + (@layout_refs_from_erb || []).each do |layout_reference| + graph["__layout_refs__"] ||= [] + graph["__layout_refs__"] << layout_reference + end + + graph + end + + def find_unused_by_reachability(render_graph, partial_files, ruby_references, dynamic_prefixes, _view_root) + reachable = Set.new + queue = [] #: Array[String] + + render_graph.each_key do |file| + next if file.start_with?("__") + + basename = File.basename(file) + next if basename.start_with?("_") + + queue << file + end + + ruby_references.each do |reference| + next unless reference.is_a?(String) + + reachable << reference + resolved_file = partial_files[reference] + + queue << resolved_file if resolved_file + end + + (render_graph["__layout_refs__"] || []).each do |layout_name| + reachable << layout_name + resolved_file = partial_files[layout_name] + queue << resolved_file if resolved_file + end + + visited_files = Set.new + + until queue.empty? + current_file = queue.shift + next if visited_files.include?(current_file) + + visited_files << current_file + + partial_names = render_graph[current_file] || [] + + partial_names.each do |partial_name| + next if reachable.include?(partial_name) + + reachable << partial_name + + resolved_file = partial_files[partial_name] + queue << resolved_file if resolved_file && render_graph.key?(resolved_file) + end + end + + partial_files.each_key do |name| + if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") } + reachable << name + end + end + + partial_files.except(*reachable) + end + + def partition_dynamic(render_calls) + static = [] #: Array[Hash[Symbol, untyped]] + dynamic = [] #: Array[Hash[Symbol, untyped]] + + render_calls.each do |call| + if call[:partial] && dynamic_partial?(call[:partial]) + dynamic << call + else + static << call + end + end + + [static, dynamic] + end + + def dynamic_partial?(partial_name) + partial_name.include?("\#{") || partial_name.include?("#\{") || partial_name.match?(%r{[^a-z0-9_/]}) + end + + def collect_all_dynamic_prefixes(dynamic_calls, ruby_references) + prefixes = dynamic_calls.filter_map { |call| + next unless call[:partial] + + prefix = call[:partial].gsub(/\A["']|["']\z/, "") + prefix = prefix.split("\#{").first&.chomp("/") + prefix unless prefix.nil? || prefix.empty? + } + + ruby_references.each do |reference| + prefixes << reference[:prefix] if reference.is_a?(Hash) && reference[:prefix] + end + + prefixes.concat(@dynamic_prefixes_from_erb || []) + prefixes.uniq + end + + def find_unresolved(render_calls, partial_files, view_root) + render_calls.select do |call| + next false unless call[:partial] + + !resolve_partial(call[:partial], call[:file], partial_files, view_root) + end + end + + def resolve_partial(partial_name, source_file, partial_files, view_root) + return partial_files[partial_name] if partial_files.key?(partial_name) + + source_directory = begin + Pathname.new(File.dirname(source_file)).relative_path_from(view_root).to_s + rescue ArgumentError + nil + end + + if source_directory && source_directory != "." + relative_name = "#{source_directory}/#{partial_name}" + return partial_files[relative_name] if partial_files.key?(relative_name) + end + + unless partial_name.include?("/") + application_name = "application/#{partial_name}" + return partial_files[application_name] if partial_files.key?(application_name) + end + + nil + end + + def expected_file_path(partial_name, view_root) + parts = partial_name.split("/") + parts[-1] = "_#{parts[-1]}" + relative = parts.join("/") + + relative_root = relative_path(view_root.to_s) + + Herb::PARTIAL_EXTENSIONS.map { |extension| "#{relative_root}/#{relative}#{extension}" }.join(", ") + end + + def label(text, width = 12) + dimmed(text.ljust(width)) + end + + def stat(count, text, color) + value = "#{count} #{text}" + + if count.positive? + bold(send(color, value)) + else + bold(green(value)) + end + end + + def separator + dimmed("\u2500" * 60) + end + + def pluralize(count, singular, plural = nil) + count == 1 ? singular : (plural || "#{singular}s") + end + + def format_duration(seconds) + if seconds < 1 + "#{(seconds * 1000).round(2)}ms" + elsif seconds < 60 + "#{seconds.round(2)}s" + else + minutes = (seconds / 60).to_i + remaining_seconds = seconds % 60 + + "#{minutes}m #{remaining_seconds.round(2)}s" + end + end + + def relative_path(path) + Pathname.new(path).relative_path_from(Pathname.pwd).to_s + rescue ArgumentError + path.to_s + end + end + + class RenderCallVisitor < Visitor + attr_reader :render_calls + + def initialize(file) + @file = file + @render_calls = [] + end + + def visit_erb_render_node(node) + call = { file: @file } + + call[:partial] = node.partial_path if node.static_partial? + call[:template_path] = node.template_name if node.template_name + call[:layout] = node.layout_name if node.layout_name + call[:file_path] = node.keywords&.file&.value if node.keywords&.file + call[:inline] = true if node.keywords&.inline_template + call[:renderable] = node.keywords&.renderable&.value if node.keywords&.renderable + call[:dynamic] = true if node.dynamic? + + call[:body] = true if node.keywords&.body + call[:plain] = true if node.keywords&.plain + call[:html] = true if node.keywords&.html + + if node.location + call[:location] = "#{node.location.start.line}:#{node.location.start.column}" + end + + @render_calls << call + + super + end + end + end +end diff --git a/lib/herb/ast/erb_render_node.rb b/lib/herb/ast/erb_render_node.rb index f1f01f215..5718e2b71 100644 --- a/lib/herb/ast/erb_render_node.rb +++ b/lib/herb/ast/erb_render_node.rb @@ -8,27 +8,27 @@ class ERBRenderNode < Node PARTIAL_EXTENSIONS = Herb::PARTIAL_EXTENSIONS def static_partial? - partial && !partial.value.empty? + keywords&.partial && !keywords&.partial&.value&.empty? end def dynamic? - !static_partial? && (object || renderable) + !static_partial? && (keywords&.object || keywords&.renderable) end def partial_path - partial&.value + keywords&.partial&.value end def template_name - template_path&.value + keywords&.template_path&.value end def layout_name - layout&.value + keywords&.layout&.value end def local_names - locals.map { |local| local.name&.value }.compact + keywords&.locals&.map { |local| local.name&.value }&.compact || [] end def resolve(view_root: nil, source_directory: nil) @@ -54,7 +54,7 @@ def candidate_paths(name = nil, view_root = nil, source_directory = nil) source_directory = Pathname.new(source_directory) if source_directory && !source_directory.is_a?(Pathname) PARTIAL_EXTENSIONS.flat_map do |extension| - paths = [] + paths = [] #: Array[Pathname] if directory paths << view_root.join(directory, "_#{base}#{extension}") if view_root @@ -72,7 +72,7 @@ def similar_partials(view_root: nil, source_directory: nil, limit: 3) return [] unless name - suggestions = [] + suggestions = [] #: Array[String] if view_root view_root = Pathname.new(view_root) unless view_root.is_a?(Pathname) @@ -113,7 +113,7 @@ def find_non_partial_matches(name = nil, view_root = nil, source_directory = nil return [] unless name - matches = [] + matches = [] #: Array[String] PARTIAL_EXTENSIONS.each do |extension| if name.include?("/") diff --git a/lib/herb/cli.rb b/lib/herb/cli.rb index a663651d4..588c26b14 100644 --- a/lib/herb/cli.rb +++ b/lib/herb/cli.rb @@ -106,7 +106,6 @@ def help(exit_code = 0) bundle exec herb actionview check [path] Check if render calls resolve to valid partial files. bundle exec herb actionview graph [path] Show render dependency graph for a project or file. - bundle exec herb actionview compile [file] Compile ERB template with ActionView framework support. bundle exec herb actionview render [file] Render ERB template using ActionView helpers. bundle exec herb lint [patterns] Lint templates (delegates to @herb-tools/linter) @@ -370,22 +369,40 @@ def run_actionview_command subcommand = @args[1] @file = @args[2] + target_path = @file ? File.expand_path(@file) : Dir.pwd + target_directory = File.directory?(target_path) ? target_path : File.dirname(target_path) + config = Herb::Configuration.new(target_directory) + + if !(subcommand == "help" || subcommand.nil?) && (config.framework != "actionview") + project = config.project_root || target_directory + abort <<~MESSAGE + Herb also works outside of ActionView, but the `herb actionview` commands require the project to be explicitly configured for ActionView. + + The project at '#{project}' is not configured to use ActionView (current framework: '#{config.framework}'). + + To enable ActionView support, add the following to your `.herb.yml`: + + framework: actionview + MESSAGE + end + case subcommand when "check" - require_relative "render_checker" + require_relative "action_view/render_analyzer" - path = @file || "." + path = File.expand_path(@file || ".") unless File.directory?(path) puts "Not a directory: '#{path}'." exit(1) end - checker = Herb::RenderChecker.new(path) - has_issues = checker.check! + analyzer = Herb::ActionView::RenderAnalyzer.new(path) + has_issues = analyzer.check! + exit(has_issues ? 1 : 0) when "graph" - require_relative "render_checker" + require_relative "action_view/render_analyzer" path = @file || "." @@ -394,19 +411,17 @@ def run_actionview_command exit(1) end - checker = Herb::RenderChecker.new(File.directory?(path) ? path : File.dirname(path)) + path = File.expand_path(path) + project_root = File.directory?(path) ? path : config.project_root&.to_s || File.dirname(path) + analyzer = Herb::ActionView::RenderAnalyzer.new(project_root) if File.file?(path) - checker.graph_file!(path) + analyzer.graph_file!(path) else - checker.graph! + analyzer.graph! end exit(0) - when "compile" - @file = @args[2] - @action_view = true - compile_template when "render" @file = @args[2] actionview_render @@ -420,14 +435,12 @@ def run_actionview_command Subcommands: check [path] Check if render calls resolve to valid partial files graph [path] Show render dependency graph for a project or file - compile [file] Compile ERB template with ActionView framework support render [file] Render ERB template using ActionView helpers Examples: - bundle exec herb actionview check . - bundle exec herb actionview graph . + bundle exec herb actionview check + bundle exec herb actionview graph bundle exec herb actionview graph app/views/posts/show.html.erb - bundle exec herb actionview compile app/views/posts/show.html.erb bundle exec herb actionview render app/views/posts/show.html.erb HELP @@ -671,7 +684,6 @@ def compile_template options[:optimize] = true if optimize options[:trim] = true if trim options[:validate_ruby] = true - options[:framework] = :action_view if @action_view engine = Herb::Engine.new(file_content, options) if json @@ -758,7 +770,6 @@ def render_template options[:debug_filename] = @file if @file end - options[:framework] = :action_view if @action_view options[:optimize] = true if optimize options[:trim] = true if trim diff --git a/lib/herb/render_checker.rb b/lib/herb/render_checker.rb deleted file mode 100644 index b83e205c7..000000000 --- a/lib/herb/render_checker.rb +++ /dev/null @@ -1,1008 +0,0 @@ -# frozen_string_literal: true - -require "pathname" -require "set" - -module Herb - class RenderChecker - include Colors - - Result = Struct.new( - :render_calls, :dynamic_calls, :partial_files, - :unresolved, :unused, :view_root, - keyword_init: true - ) do - def issues? - unresolved.any? || unused.any? - end - end - - attr_reader :project_path, :configuration - - def initialize(project_path, configuration: nil) - @project_path = Pathname.new(File.expand_path(project_path)) - @configuration = configuration || Configuration.load(@project_path.to_s) - end - - def check! - start_time = Time.now - - erb_files = find_erb_files - view_root = find_view_root - - if erb_files.empty? - puts "No ERB files found." - return false - end - - puts "" - puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" - puts "" - - if configuration.config_path - puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}" - else - puts dimmed("No .herb.yml found, using defaults") - end - - puts dimmed("Checking render calls in #{erb_files.count} #{pluralize(erb_files.count, "file")}...") - - result = analyze(erb_files, view_root) - duration = Time.now - start_time - - print_results(result, duration) - - result.issues? - end - - def graph_file!(file_path) - erb_files = find_erb_files - view_root = find_view_root - - if erb_files.empty? - puts "No ERB files found in project." - return - end - - render_calls_by_file = collect_render_calls_by_file(erb_files) - ruby_partial_references = collect_ruby_render_references - partial_files = find_partial_files(view_root) - render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) - - all_render_calls = render_calls_by_file.values.flatten - _, dynamic_calls = partition_dynamic(all_render_calls) - dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references) - reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes) - reverse_graph = Hash.new { |hash, key| hash[key] = [] } - - render_graph.each do |file, partial_names| - next if file.start_with?("__") - - partial_names.each do |partial_name| - reverse_graph[partial_name] << file - end - end - - ruby_partial_references.each do |reference| - next unless reference.is_a?(String) - - reverse_graph[reference] << "__ruby__" - end - - basename = File.basename(file_path) - is_partial = basename.start_with?("_") - - puts "" - - if is_partial - partial_name = partial_name_for_file(file_path, view_root) - - unless partial_name - puts "Could not determine partial name for: #{file_path}" - return - end - - display = file_display_name(file_path, view_root) - status = reachable.include?(partial_name) ? green("\u2713") : yellow("~") - - puts " #{status} #{bold(partial_name)} #{dimmed(display)}" - puts "" - - callers = reverse_graph[partial_name] - - if callers.any? - puts " #{bold("Rendered by:")}" - - callers.each do |caller_file| - if caller_file == "__ruby__" - puts " \u251c\u2500\u2500 #{dimmed("[Ruby code]")}" - else - caller_display = file_display_name(caller_file, view_root) - caller_basename = File.basename(caller_file) - caller_status = caller_basename.start_with?("_") ? dimmed("(partial)") : dimmed("(entry point)") - puts " \u251c\u2500\u2500 #{cyan(caller_display)} #{caller_status}" - end - end - - puts "" - puts " #{bold("Reachable from:")}" - entry_chains = trace_to_entry_points(partial_name, reverse_graph, partial_files, view_root) - - if entry_chains.any? - entry_chains.each do |chain| - chain_display = chain.map { |name| cyan(name) }.join(dimmed(" \u2190 ")) - puts " \u251c\u2500\u2500 #{chain_display}" - end - else - puts " #{yellow("(not reachable from any entry point)")}" - end - else - puts " #{dimmed("Not rendered by any file.")}" - end - - children = render_graph[file_path] || [] - - if children.any? - puts "" - puts " #{bold("Renders:")}" - print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) - end - else - display = file_display_name(file_path, view_root) - puts " #{cyan(display)} #{dimmed("(entry point)")}" - - children = render_graph[file_path] || [] - - puts "" - if children.any? - puts " #{bold("Renders:")}" - print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) - else - puts " #{dimmed("No render calls in this file.")}" - end - end - - puts "" - end - - def graph! - erb_files = find_erb_files - view_root = find_view_root - - if erb_files.empty? - puts "No ERB files found." - return - end - - puts "" - puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" - puts "" - - if configuration.config_path - puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}" - else - puts dimmed("No .herb.yml found, using defaults") - end - - puts dimmed("Building render graph for #{erb_files.count} #{pluralize(erb_files.count, "file")}...") - - render_calls_by_file = collect_render_calls_by_file(erb_files) - ruby_partial_references = collect_ruby_render_references - partial_files = find_partial_files(view_root) - render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) - - all_render_calls = render_calls_by_file.values.flatten - _, dynamic_calls = partition_dynamic(all_render_calls) - dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references) - reverse_graph = Hash.new { |hash, key| hash[key] = [] } - - render_graph.each do |file, partial_names| - next if file.start_with?("__") - - display_name = file_display_name(file, view_root) - - partial_names.each do |partial_name| - reverse_graph[partial_name] << display_name - end - end - - ruby_partial_references.each do |reference| - next unless reference.is_a?(String) - - reverse_graph[reference] << "#{dimmed("[Ruby]")} #{reference}" - end - - entry_points = render_graph.keys.reject { |file| - file.start_with?("__") || File.basename(file).start_with?("_") - }.sort - - reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes) - - puts "" - puts separator - puts "" - - if entry_points.any? - puts " #{bold("Entry points:")} #{dimmed("(#{entry_points.count} #{pluralize(entry_points.count, "template")})")}" - - entry_points.each do |file| - display = file_display_name(file, view_root) - partials = render_graph[file] || [] - - puts "" - puts " #{cyan(display)}" - - if partials.empty? - puts " #{dimmed("(no render calls)")}" - else - print_partial_tree(partials, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) - end - end - end - - ruby_static_references = ruby_partial_references.select { |reference| reference.is_a?(String) } - - if ruby_static_references.any? - puts "" - puts " #{separator}" - puts "" - puts " #{bold("Ruby references:")} #{dimmed("(#{ruby_static_references.count} #{pluralize(ruby_static_references.count, "partial")})")}" - - ruby_static_references.sort.each do |reference| - resolved = partial_files[reference] - status = resolved ? green("\u2713") : red("\u2717") - puts "" - puts " #{status} #{bold(reference)}" - - if resolved - children = render_graph[resolved] || [] - print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) - end - end - end - - unreachable = partial_files.except(*reachable) - - puts "" - puts " #{separator}" - puts "" - puts " #{bold("Partial usage:")} #{dimmed("(who renders each partial)")}" - - partial_files.keys.sort.each do |name| - callers = reverse_graph[name] - status = reachable.include?(name) ? green("\u2713") : yellow("~") - - puts "" - puts " #{status} #{bold(name)}" - - if callers.any? - callers.sort.each_with_index do |caller_name, index| - connector = index == callers.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" - puts " #{connector} #{dimmed("rendered by")} #{caller_name}" - end - else - puts " #{dimmed("(not rendered by any file)")}" - end - end - - if unreachable.any? - puts "" - puts " #{separator}" - puts "" - puts " #{bold(yellow("Unreachable partials:"))} #{dimmed("(#{unreachable.count} #{pluralize(unreachable.count, "file")})")}" - - unreachable.each do |name, file| - display = file_display_name(file, view_root) - children = render_graph[file] || [] - - puts "" - puts " #{yellow("~")} #{bold(name)} #{dimmed(display)}" - - if children.any? - print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new) - end - end - end - - puts "" - puts " #{separator}" - puts "" - puts " #{bold("Summary:")}" - puts " #{label("Entry points")} #{cyan(entry_points.count.to_s)}" - puts " #{label("Partials")} #{cyan(partial_files.count.to_s)}" - puts " #{label("Reachable")} #{bold(green(reachable.count.to_s))}" - puts " #{label("Unreachable")} #{unreachable.any? ? bold(yellow(unreachable.count.to_s)) : bold(green("0"))}" - puts "" - end - - def analyze(erb_files = nil, view_root = nil) - erb_files ||= find_erb_files - view_root ||= find_view_root - - render_calls_by_file = collect_render_calls_by_file(erb_files) - ruby_partial_references = collect_ruby_render_references - partial_files = find_partial_files(view_root) - - all_render_calls = render_calls_by_file.values.flatten - static_calls, dynamic_calls = partition_dynamic(all_render_calls) - - render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) - - unresolved = find_unresolved(static_calls, partial_files, view_root) - - unused = find_unused_by_reachability( - render_graph, partial_files, ruby_partial_references, - collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references), - view_root - ) - - Result.new( - render_calls: all_render_calls, - dynamic_calls: dynamic_calls, - partial_files: partial_files, - unresolved: unresolved, - unused: unused, - view_root: view_root - ) - end - - def analyze_from_collected(render_calls_by_file:, dynamic_prefixes_from_erb: [], layout_refs_from_erb: []) - view_root = find_view_root - - @dynamic_prefixes_from_erb = dynamic_prefixes_from_erb - @layout_refs_from_erb = layout_refs_from_erb - - ruby_partial_references = collect_ruby_render_references - partial_files = find_partial_files(view_root) - - all_render_calls = render_calls_by_file.values.flatten - static_calls, dynamic_calls = partition_dynamic(all_render_calls) - - render_graph = build_render_graph(render_calls_by_file, partial_files, view_root) - - unresolved = find_unresolved(static_calls, partial_files, view_root) - - unused = find_unused_by_reachability( - render_graph, partial_files, ruby_partial_references, - collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references), - view_root - ) - - Result.new( - render_calls: all_render_calls, - dynamic_calls: dynamic_calls, - partial_files: partial_files, - unresolved: unresolved, - unused: unused, - view_root: view_root - ) - end - - def print_file_lists(result) - return unless result.issues? - - if result.unresolved.any? - puts "\n" - puts " #{bold("Unresolved render calls:")}" - puts " #{dimmed("These render calls reference partials that could not be found on disk.")}" - - grouped = result.unresolved.group_by { |call| call[:file] } - - grouped.each do |file, calls| - relative = relative_path(file) - - puts "" - puts " #{cyan(relative)}:" - - calls.each do |call| - location = call[:location] ? dimmed("at #{call[:location]}") : nil - expected = expected_file_path(call[:partial], result.view_root) - puts " #{red("\u2717")} #{bold(call[:partial])} #{location} #{dimmed("-")} #{dimmed(expected)}" - end - end - end - - return unless result.unused.any? - - puts "\n #{separator}" if result.unresolved.any? - puts "\n" - puts " #{bold("Unused partials:")}" - puts " #{dimmed("These partial files are not referenced by any reachable render call.")}" - - result.unused.each do |name, file| - relative = relative_path(file) - - puts "" - puts " #{cyan(relative)}:" - puts " #{yellow("~")} #{bold(name)} #{dimmed("not referenced")}" - end - end - - def print_issue_summary(result) - return unless result.issues? - - if result.unresolved.any? - files_count = result.unresolved.map { |call| call[:file] }.uniq.count - - puts " #{white("Unresolved partials")} #{dimmed("(#{result.unresolved.count} #{pluralize(result.unresolved.count, "reference")} in #{files_count} #{pluralize(files_count, "file")})")}" - end - - return unless result.unused.any? - - puts " #{white("Unused partials")} #{dimmed("(#{result.unused.count} #{pluralize(result.unused.count, "file")})")}" - end - - def print_summary_line(result) - render_parts = [] - - partials_only = result.render_calls.count { |call| call[:partial] } - render_parts << stat(result.render_calls.count, "total", :green) - render_parts << stat(partials_only, "with partial", :green) - render_parts << stat(result.dynamic_calls.count, "dynamic", :yellow) if result.dynamic_calls.any? - other_count = result.render_calls.count - partials_only - render_parts << stat(other_count, "other", :green) if other_count.positive? - - partial_parts = [] - partial_parts << stat(result.partial_files.count, "on disk", :green) - partial_parts << stat(result.unresolved.count, "unresolved", :red) if result.unresolved.any? - partial_parts << stat(result.unused.count, "unused", :yellow) if result.unused.any? - - puts " #{label("Renders")} #{render_parts.join(" | ")}" - puts " #{label("Partials")} #{partial_parts.join(" | ")}" - end - - private - - def print_partial_tree(partial_names, render_graph, partial_files, view_root, reachable, indent: "", visited: Set.new) - partial_names.each_with_index do |name, index| - is_last = index == partial_names.size - 1 - connector = is_last ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" - child_indent = is_last ? " " : "\u2502 " - - resolved_file = partial_files[name] - status = if !resolved_file - red("\u2717") - elsif reachable.include?(name) - green("\u2713") - else - yellow("~") - end - - puts "#{indent}#{connector} #{status} #{name}" - - next unless resolved_file - next if visited.include?(name) - - visited.add(name) - - children = render_graph[resolved_file] || [] - - if children.any? - print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: "#{indent}#{child_indent}", visited: visited) - end - end - end - - def file_display_name(file, view_root) - Pathname.new(file).relative_path_from(view_root).to_s - rescue ArgumentError - relative_path(file) - end - - def compute_reachable(render_graph, partial_files, ruby_references, dynamic_prefixes) - reachable = Set.new - queue = [] - - render_graph.each_key do |file| - next if file.start_with?("__") - - basename = File.basename(file) - next if basename.start_with?("_") - - queue << file - end - - ruby_references.each do |reference| - next unless reference.is_a?(String) - - reachable << reference - resolved_file = partial_files[reference] - queue << resolved_file if resolved_file - end - - (render_graph["__layout_refs__"] || []).each do |layout_name| - reachable << layout_name - resolved_file = partial_files[layout_name] - queue << resolved_file if resolved_file - end - - visited_files = Set.new - - until queue.empty? - current_file = queue.shift - next if visited_files.include?(current_file) - - visited_files << current_file - - partial_names = render_graph[current_file] || [] - - partial_names.each do |partial_name| - next if reachable.include?(partial_name) - - reachable << partial_name - - resolved_file = partial_files[partial_name] - queue << resolved_file if resolved_file && render_graph.key?(resolved_file) - end - end - - partial_files.each_key do |name| - if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") } - reachable << name - end - end - - reachable - end - - def trace_to_entry_points(partial_name, reverse_graph, _partial_files, view_root) - chains = [] - queue = [[partial_name]] - visited = Set.new([partial_name]) - - until queue.empty? - current_chain = queue.shift - - current_name = current_chain.last - callers = reverse_graph[current_name] || [] - - callers.each do |caller_file| - next if caller_file == "__ruby__" - - caller_basename = File.basename(caller_file) - - if caller_basename.start_with?("_") - caller_name = partial_name_for_file(caller_file, view_root) - next unless caller_name - next if visited.include?(caller_name) - - visited.add(caller_name) - queue << (current_chain + [caller_name]) - else - entry_display = file_display_name(caller_file, view_root) - chains << (current_chain + [entry_display]) - end - end - - if callers.include?("__ruby__") - chains << (current_chain + ["[Ruby code]"]) - end - end - - chains - end - - def print_results(result, duration) - if result.issues? - puts "" - puts separator - end - - print_file_lists(result) - - if result.issues? - puts "\n #{separator}" - puts "\n" - puts " #{bold("Issue summary:")}" - print_issue_summary(result) - end - - puts "\n #{separator}" - - scanned_files = result.render_calls.map { |call| call[:file] }.uniq.count - issues = result.unresolved.count + result.unused.count - - puts "\n" - puts " #{bold("Summary:")}" - - puts " #{label("Version")} #{cyan(Herb.version)}" - puts " #{label("Checked")} #{cyan("#{scanned_files} #{pluralize(scanned_files, "file")}")}" - - print_summary_line(result) - - puts " #{label("Duration")} #{cyan(format_duration(duration))}" - - if issues.zero? - puts "" - puts " #{bold(green("\u2713"))} #{green("All render calls resolve and all partials are used!")}" - end - - puts "" - end - - def find_erb_files - patterns = configuration.file_include_patterns - exclude = configuration.file_exclude_patterns - - files = patterns.flat_map { |pattern| Dir[File.join(@project_path, pattern)] }.uniq - - files.reject do |file| - relative = Pathname.new(file).relative_path_from(@project_path).to_s - exclude.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) } - end.sort - end - - def find_view_root - candidates = [ - @project_path.join("app", "views"), - @project_path - ] - - candidates.find(&:directory?) || @project_path - end - - def find_partial_files(view_root) - return {} unless view_root.directory? - - partials = {} - - Dir[File.join(view_root, "**", Herb::PARTIAL_GLOB_PATTERN)].each do |file| - partial_name = partial_name_for_file(file, view_root) - partials[partial_name] = file if partial_name - end - - partials - end - - def partial_name_for_file(file_path, view_root) - relative = Pathname.new(file_path).relative_path_from(view_root).to_s - - directory = File.dirname(relative) - basename = File.basename(relative) - - return nil unless basename.start_with?("_") - - name = basename.sub(/\A_/, "").sub(/\..*\z/, "") - - if directory == "." - name - else - "#{directory}/#{name}" - end - end - - def collect_render_calls_by_file(files) - @dynamic_prefixes_from_erb = [] - @layout_refs_from_erb = [] - - ensure_parallel! - - file_results = Parallel.map(files, in_processes: Parallel.processor_count) do |file| - process_file_for_render_calls(file) - end - - render_calls_by_file = {} - - file_results.each do |file_result| - next unless file_result - - render_calls_by_file[file_result[:file]] = file_result[:calls] - @dynamic_prefixes_from_erb.concat(file_result[:dynamic_prefixes]) - @layout_refs_from_erb.concat(file_result[:layout_references]) - end - - render_calls_by_file - end - - def process_file_for_render_calls(file) - content = File.read(file) - result = Herb.parse(content, render_nodes: true) - - visitor = RenderCallVisitor.new(file) - visitor.visit(result.value) - calls = visitor.render_calls.dup - - visitor_partials = calls.filter_map { |call| call[:partial] }.to_set - - dynamic_prefixes = [] - layout_references = [] - - content.scan(%r{render[\s(]+(?:partial:\s*)?["']([a-z0-9_/]+)["']}) do |match| - partial = match[0] - next if visitor_partials.include?(partial) - - calls << { file: file, partial: partial } - end - - content.scan(%r{render\s+(?:partial:\s*)?["']([a-z0-9_/]+)/\#\{}) do |match| - dynamic_prefixes << match[0] - end - - content.scan(%r{render\s+layout:\s*["']([a-z0-9_/]+)["']}) do |match| - layout_references << match[0] - end - - { file: file, calls: calls, dynamic_prefixes: dynamic_prefixes, layout_references: layout_references } - rescue StandardError => e - warn "Warning: Could not parse #{file}: #{e.message}" - nil - end - - def ensure_parallel! - return if defined?(Parallel) - - require "bundler/inline" - - gemfile(true, quiet: true) do - source "https://rubygems.org" - gem "parallel" - end - end - - def collect_ruby_render_references - references = [] - - ruby_directories = [ - @project_path.join("app"), - @project_path.join("lib") - ] - - ruby_directories.each do |directory| - next unless directory.directory? - - Dir[File.join(directory, "**", "*.rb")].each do |file| - content = File.read(file) - - content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)["']}) do |match| - references << match[0] - end - - content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)/\#\{}) do |match| - references << { prefix: match[0] } - end - rescue StandardError - next - end - end - - references - end - - def build_render_graph(render_calls_by_file, partial_files, view_root) - graph = {} - - render_calls_by_file.each do |file, calls| - resolved_names = [] - - calls.each do |call| - partial_reference = call[:partial] || call[:layout] - next unless partial_reference - - resolved = resolve_partial(partial_reference, file, partial_files, view_root) - if resolved - resolved_name = partial_name_for_file(resolved, view_root) - resolved_names << resolved_name if resolved_name - else - resolved_names << partial_reference - end - end - - graph[file] = resolved_names.uniq - end - - (@layout_refs_from_erb || []).each do |layout_reference| - graph["__layout_refs__"] ||= [] - graph["__layout_refs__"] << layout_reference - end - - graph - end - - def find_unused_by_reachability(render_graph, partial_files, ruby_references, dynamic_prefixes, _view_root) - reachable = Set.new - queue = [] - - render_graph.each_key do |file| - next if file.start_with?("__") - - basename = File.basename(file) - next if basename.start_with?("_") - - queue << file - end - - ruby_references.each do |reference| - next unless reference.is_a?(String) - - reachable << reference - resolved_file = partial_files[reference] - - queue << resolved_file if resolved_file - end - - (render_graph["__layout_refs__"] || []).each do |layout_name| - reachable << layout_name - resolved_file = partial_files[layout_name] - queue << resolved_file if resolved_file - end - - visited_files = Set.new - - until queue.empty? - current_file = queue.shift - next if visited_files.include?(current_file) - - visited_files << current_file - - partial_names = render_graph[current_file] || [] - - partial_names.each do |partial_name| - next if reachable.include?(partial_name) - - reachable << partial_name - - resolved_file = partial_files[partial_name] - queue << resolved_file if resolved_file && render_graph.key?(resolved_file) - end - end - - partial_files.each_key do |name| - if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") } - reachable << name - end - end - - partial_files.except(*reachable) - end - - def partition_dynamic(render_calls) - static = [] - dynamic = [] - - render_calls.each do |call| - if call[:partial] && dynamic_partial?(call[:partial]) - dynamic << call - else - static << call - end - end - - [static, dynamic] - end - - def dynamic_partial?(partial_name) - partial_name.include?("\#{") || partial_name.include?("#\{") || partial_name.match?(%r{[^a-z0-9_/]}) - end - - def collect_all_dynamic_prefixes(dynamic_calls, ruby_references) - prefixes = dynamic_calls.filter_map { |call| - next unless call[:partial] - - prefix = call[:partial].gsub(/\A["']|["']\z/, "") - prefix = prefix.split("\#{").first&.chomp("/") - prefix unless prefix.nil? || prefix.empty? - } - - ruby_references.each do |reference| - prefixes << reference[:prefix] if reference.is_a?(Hash) && reference[:prefix] - end - - prefixes.concat(@dynamic_prefixes_from_erb || []) - prefixes.uniq - end - - def find_unresolved(render_calls, partial_files, view_root) - render_calls.select do |call| - next false unless call[:partial] - - !resolve_partial(call[:partial], call[:file], partial_files, view_root) - end - end - - def resolve_partial(partial_name, source_file, partial_files, view_root) - return partial_files[partial_name] if partial_files.key?(partial_name) - - source_directory = begin - Pathname.new(File.dirname(source_file)).relative_path_from(view_root).to_s - rescue ArgumentError - nil - end - - if source_directory && source_directory != "." - relative_name = "#{source_directory}/#{partial_name}" - return partial_files[relative_name] if partial_files.key?(relative_name) - end - - unless partial_name.include?("/") - application_name = "application/#{partial_name}" - return partial_files[application_name] if partial_files.key?(application_name) - end - - nil - end - - def expected_file_path(partial_name, view_root) - parts = partial_name.split("/") - parts[-1] = "_#{parts[-1]}" - relative = parts.join("/") - - relative_root = relative_path(view_root.to_s) - - Herb::PARTIAL_EXTENSIONS.map { |extension| "#{relative_root}/#{relative}#{extension}" }.join(", ") - end - - def label(text, width = 12) - dimmed(text.ljust(width)) - end - - def stat(count, text, color) - value = "#{count} #{text}" - - if count.positive? - bold(send(color, value)) - else - bold(green(value)) - end - end - - def separator - dimmed("\u2500" * 60) - end - - def pluralize(count, singular, plural = nil) - count == 1 ? singular : (plural || "#{singular}s") - end - - def format_duration(seconds) - if seconds < 1 - "#{(seconds * 1000).round(2)}ms" - elsif seconds < 60 - "#{seconds.round(2)}s" - else - minutes = (seconds / 60).to_i - remaining_seconds = seconds % 60 - - "#{minutes}m #{remaining_seconds.round(2)}s" - end - end - - def relative_path(path) - Pathname.new(path).relative_path_from(Pathname.pwd).to_s - rescue ArgumentError - path.to_s - end - end - - class RenderCallVisitor < Visitor - attr_reader :render_calls - - def initialize(file) - @file = file - @render_calls = [] - end - - def visit_erb_render_node(node) - call = { file: @file } - - call[:partial] = node.partial_path if node.static_partial? - call[:template_path] = node.template_name if node.template_name - call[:layout] = node.layout_name if node.layout_name - call[:file_path] = node.file&.value if node.file - call[:inline] = true if node.inline_template - call[:renderable] = node.renderable&.value if node.renderable - call[:dynamic] = true if node.dynamic? - - [:body, :plain, :html].each do |field| - call[field] = true if node.send(field) - end - - if node.location - call[:location] = "#{node.location.start.line}:#{node.location.start.column}" - end - - @render_calls << call - - super - end - end -end diff --git a/sig/herb.rbs b/sig/herb.rbs index 44c7b058f..605f466f8 100644 --- a/sig/herb.rbs +++ b/sig/herb.rbs @@ -1,5 +1,11 @@ # Generated from lib/herb.rb with RBS::Inline +module Herb + PARTIAL_EXTENSIONS: untyped + + PARTIAL_GLOB_PATTERN: ::String +end + module Herb # : (String path, ?arena_stats: bool) -> LexResult def self.lex_file: (String path, ?arena_stats: bool) -> LexResult diff --git a/sig/herb/action_view/render_analyzer.rbs b/sig/herb/action_view/render_analyzer.rbs new file mode 100644 index 000000000..3bde525fd --- /dev/null +++ b/sig/herb/action_view/render_analyzer.rbs @@ -0,0 +1,122 @@ +# Generated from lib/herb/action_view/render_analyzer.rb with RBS::Inline + +module Herb + module ActionView + class RenderAnalyzer + include Colors + + class Result < Data + attr_reader render_calls(): untyped + + attr_reader dynamic_calls(): untyped + + attr_reader partial_files(): untyped + + attr_reader unresolved(): untyped + + attr_reader unused(): untyped + + attr_reader view_root(): untyped + + def self.new: (untyped render_calls, untyped dynamic_calls, untyped partial_files, untyped unresolved, untyped unused, untyped view_root) -> instance + | (render_calls: untyped, dynamic_calls: untyped, partial_files: untyped, unresolved: untyped, unused: untyped, view_root: untyped) -> instance + + def self.members: () -> [ :render_calls, :dynamic_calls, :partial_files, :unresolved, :unused, :view_root ] + + def members: () -> [ :render_calls, :dynamic_calls, :partial_files, :unresolved, :unused, :view_root ] + end + + class Result + def issues?: () -> untyped + end + + attr_reader project_path: untyped + + attr_reader configuration: untyped + + def initialize: (untyped project_path, ?configuration: untyped) -> untyped + + def check!: () -> untyped + + def fully_resolvable?: (untyped file_path) -> untyped + + def graph_file!: (untyped file_path) -> untyped + + def graph!: () -> untyped + + def analyze: (?untyped erb_files, ?untyped view_root) -> untyped + + def analyze_from_collected: (render_calls_by_file: untyped, ?dynamic_prefixes_from_erb: untyped, ?layout_refs_from_erb: untyped) -> untyped + + def print_file_lists: (untyped result) -> untyped + + def print_issue_summary: (untyped result) -> untyped + + def print_summary_line: (untyped result) -> untyped + + private + + def print_partial_tree: (untyped partial_names, untyped render_graph, untyped partial_files, untyped view_root, untyped reachable, ?indent: untyped, ?visited: untyped) -> untyped + + def file_display_name: (untyped file, untyped view_root) -> untyped + + def compute_reachable: (untyped render_graph, untyped partial_files, untyped ruby_references, untyped dynamic_prefixes) -> untyped + + def trace_to_entry_points: (untyped partial_name, untyped reverse_graph, untyped _partial_files, untyped view_root) -> untyped + + def print_results: (untyped result, untyped duration) -> untyped + + def find_erb_files: () -> untyped + + def find_view_root: () -> untyped + + def find_partial_files: (untyped view_root) -> untyped + + def partial_name_for_file: (untyped file_path, untyped view_root) -> untyped + + def collect_render_calls_by_file: (untyped files) -> untyped + + def process_file_for_render_calls: (untyped file) -> untyped + + def ensure_parallel!: () -> untyped + + def collect_ruby_render_references: () -> untyped + + def build_render_graph: (untyped render_calls_by_file, untyped partial_files, untyped view_root) -> untyped + + def find_unused_by_reachability: (untyped render_graph, untyped partial_files, untyped ruby_references, untyped dynamic_prefixes, untyped _view_root) -> untyped + + def partition_dynamic: (untyped render_calls) -> untyped + + def dynamic_partial?: (untyped partial_name) -> untyped + + def collect_all_dynamic_prefixes: (untyped dynamic_calls, untyped ruby_references) -> untyped + + def find_unresolved: (untyped render_calls, untyped partial_files, untyped view_root) -> untyped + + def resolve_partial: (untyped partial_name, untyped source_file, untyped partial_files, untyped view_root) -> untyped + + def expected_file_path: (untyped partial_name, untyped view_root) -> untyped + + def label: (untyped text, ?untyped width) -> untyped + + def stat: (untyped count, untyped text, untyped color) -> untyped + + def separator: () -> untyped + + def pluralize: (untyped count, untyped singular, ?untyped plural) -> untyped + + def format_duration: (untyped seconds) -> untyped + + def relative_path: (untyped path) -> untyped + end + + class RenderCallVisitor < Visitor + attr_reader render_calls: untyped + + def initialize: (untyped file) -> untyped + + def visit_erb_render_node: (untyped node) -> untyped + end + end +end diff --git a/sig/herb/ast/erb_render_node.rbs b/sig/herb/ast/erb_render_node.rbs new file mode 100644 index 000000000..056a11868 --- /dev/null +++ b/sig/herb/ast/erb_render_node.rbs @@ -0,0 +1,29 @@ +# Generated from lib/herb/ast/erb_render_node.rb with RBS::Inline + +module Herb + module AST + class ERBRenderNode < Node + PARTIAL_EXTENSIONS: untyped + + def static_partial?: () -> untyped + + def dynamic?: () -> untyped + + def partial_path: () -> untyped + + def template_name: () -> untyped + + def layout_name: () -> untyped + + def local_names: () -> untyped + + def resolve: (?view_root: untyped, ?source_directory: untyped) -> untyped + + def candidate_paths: (?untyped name, ?untyped view_root, ?untyped source_directory) -> untyped + + def similar_partials: (?view_root: untyped, ?source_directory: untyped, ?limit: untyped) -> untyped + + def find_non_partial_matches: (?untyped name, ?untyped view_root, ?untyped source_directory) -> untyped + end + end +end diff --git a/sig/herb/engine/validators/render_validator.rbs b/sig/herb/engine/validators/render_validator.rbs new file mode 100644 index 000000000..9a1fcebbc --- /dev/null +++ b/sig/herb/engine/validators/render_validator.rbs @@ -0,0 +1,21 @@ +# Generated from lib/herb/engine/validators/render_validator.rb with RBS::Inline + +module Herb + class Engine + module Validators + class RenderValidator < Validator + def initialize: (?enabled: untyped, ?filename: untyped, ?project_path: untyped) -> untyped + + def visit_erb_render_node: (untyped node) -> untyped + + private + + def validate_partial_exists: (untyped node) -> untyped + + def find_view_root: () -> untyped + + def relative_to_project: (untyped path) -> untyped + end + end + end +end diff --git a/sig/herb_c_extension.rbs b/sig/herb_c_extension.rbs index d22036e34..730d407fd 100644 --- a/sig/herb_c_extension.rbs +++ b/sig/herb_c_extension.rbs @@ -2,7 +2,7 @@ # This file is manually maintained - not generated module Herb - def self.parse: (String input, ?track_whitespace: bool, ?analyze: bool, ?strict: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?arena_stats: bool) -> ParseResult + def self.parse: (String input, ?track_whitespace: bool, ?analyze: bool, ?strict: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?dot_notation_tags: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?html: bool, ?arena_stats: bool) -> ParseResult def self.lex: (String input, ?arena_stats: bool) -> LexResult def self.extract_ruby: (String source, ?semicolons: bool, ?comments: bool, ?preserve_positions: bool) -> String def self.extract_html: (String source) -> String diff --git a/sig/vendor/did_you_mean.rbs b/sig/vendor/did_you_mean.rbs new file mode 100644 index 000000000..168ade6ab --- /dev/null +++ b/sig/vendor/did_you_mean.rbs @@ -0,0 +1,6 @@ +module DidYouMean + class SpellChecker + def initialize: (dictionary: Array[String]) -> void + def correct: (String input) -> Array[String] + end +end diff --git a/sig/vendor/parallel.rbs b/sig/vendor/parallel.rbs new file mode 100644 index 000000000..60da1115a --- /dev/null +++ b/sig/vendor/parallel.rbs @@ -0,0 +1,4 @@ +module Parallel + def self.map: [T, U] (Array[T], ?in_processes: Integer) { (T) -> U } -> Array[U] + def self.processor_count: () -> Integer +end diff --git a/sorbet/rbi/parallel.rbi b/sorbet/rbi/parallel.rbi new file mode 100644 index 000000000..2c025164b --- /dev/null +++ b/sorbet/rbi/parallel.rbi @@ -0,0 +1,6 @@ +# typed: false + +module Parallel + def self.map(source, in_processes: nil, in_threads: nil, &block); end + def self.processor_count; end +end diff --git a/test/engine/render_nodes_test.rb b/test/engine/render_nodes_test.rb index c76af9865..9cd968abf 100644 --- a/test/engine/render_nodes_test.rb +++ b/test/engine/render_nodes_test.rb @@ -26,13 +26,12 @@ def teardown FileUtils.rm_rf(@project_path) end - def compile(template, **options) + def compile(template, **) Herb::Engine.new( template, filename: "app/views/posts/show.html.erb", project_path: @project_path, - framework: :action_view, - **options + ** ) end @@ -93,7 +92,7 @@ def render_diagnostics(template, **_options) assert_includes diagnostics.first[:message], "Partial 'missing/partial' could not be resolved" end - test "validator is disabled without action_view framework" do + test "render validator is not run during normal compilation" do engine = Herb::Engine.new( '<%= render "nonexistent" %>', filename: "app/views/posts/show.html.erb", @@ -125,5 +124,35 @@ def render_diagnostics(template, **_options) assert_equal without.src, with.src end + + test "no crash for render with file keyword" do + diagnostics = render_diagnostics('<%= render file: "shared/header" %>') + + assert_kind_of Array, diagnostics + end + + test "no crash for render with inline keyword" do + diagnostics = render_diagnostics('<%= render inline: "

Hello

" %>') + + assert_kind_of Array, diagnostics + end + + test "no crash for render with plain keyword" do + diagnostics = render_diagnostics('<%= render plain: "Hello" %>') + + assert_kind_of Array, diagnostics + end + + test "no crash for render with collection keyword" do + diagnostics = render_diagnostics('<%= render partial: "shared/header", collection: @items %>') + + assert_empty diagnostics + end + + test "no crash for render with layout keyword and block" do + diagnostics = render_diagnostics('<%= render layout: "shared/header" do %>Content<% end %>') + + assert_kind_of Array, diagnostics + end end end diff --git a/test/render_analyzer_test.rb b/test/render_analyzer_test.rb new file mode 100644 index 000000000..0cfd4362e --- /dev/null +++ b/test/render_analyzer_test.rb @@ -0,0 +1,428 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require_relative "../lib/herb/action_view/render_analyzer" +require "tmpdir" +require "fileutils" + +class RenderAnalyzerTest < Minitest::Spec + def create_project(files = {}) + directory = Dir.mktmpdir("herb-render-check-") + + files.each do |path, content| + full_path = File.join(directory, path) + FileUtils.mkdir_p(File.dirname(full_path)) + File.write(full_path, content) + end + + @directories_to_clean ||= [] + @directories_to_clean << directory + + directory + end + + def check(directory) + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + analyzer.analyze + end + + def teardown + @directories_to_clean&.each { |directory| FileUtils.rm_rf(directory) } + end + + test "no issues when all render calls resolve" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/shared/_header.html.erb" => "

Header

" + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + refute result.issues? + end + + test "detects unresolved render call" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/missing" %>' + ) + + result = check(directory) + + assert_equal 1, result.unresolved.count + assert_equal "shared/missing", result.unresolved.first[:partial] + end + + test "detects unused partial" do + directory = create_project( + "app/views/pages/index.html.erb" => "

Hello

", + "app/views/shared/_orphan.html.erb" => "

Orphan

" + ) + + result = check(directory) + + assert_equal 1, result.unused.count + assert_includes result.unused.keys, "shared/orphan" + end + + test "partial rendered from another partial is reachable" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => "

Leaf

" + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "deep transitive chain is fully reachable" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => '<%= render "shared/c" %>', + "app/views/shared/_c.html.erb" => '<%= render "shared/d" %>', + "app/views/shared/_d.html.erb" => "

Deep leaf

" + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "unreachable chain is entirely unused" do + directory = create_project( + "app/views/pages/index.html.erb" => "

No renders

", + "app/views/shared/_orphan.html.erb" => '<%= render "shared/orphan_child" %>', + "app/views/shared/_orphan_child.html.erb" => "

Child

" + ) + + result = check(directory) + + assert_equal 2, result.unused.count + assert_includes result.unused.keys, "shared/orphan" + assert_includes result.unused.keys, "shared/orphan_child" + end + + test "partial only rendered from unreachable partial is unused" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/used" %>', + "app/views/shared/_used.html.erb" => "

Used

", + "app/views/shared/_unused_parent.html.erb" => '<%= render "shared/unused_child" %>', + "app/views/shared/_unused_child.html.erb" => "

Child

" + ) + + result = check(directory) + + assert_equal 2, result.unused.count + assert_includes result.unused.keys, "shared/unused_parent" + assert_includes result.unused.keys, "shared/unused_child" + refute_includes result.unused.keys, "shared/used" + end + + test "multiple entry points can reach different partials" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/pages/about.html.erb" => '<%= render "shared/footer" %>', + "app/views/shared/_header.html.erb" => "

Header

", + "app/views/shared/_footer.html.erb" => "
Footer
" + ) + + result = check(directory) + + assert_empty result.unused + end + + test "partial reachable from one entry point but not another is still used" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/sidebar" %>', + "app/views/pages/about.html.erb" => "

No sidebar here

", + "app/views/shared/_sidebar.html.erb" => "" + ) + + result = check(directory) + + assert_empty result.unused + end + + test "resolves short partial name relative to calling file directory" do + directory = create_project( + "app/views/posts/index.html.erb" => '<%= render "card" %>', + "app/views/posts/_card.html.erb" => "
Card
" + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "resolves application/ fallback for short partial names" do + directory = create_project( + "app/views/posts/index.html.erb" => '<%= render "toolbar" %>', + "app/views/application/_toolbar.html.erb" => "" + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "layout render with block is detected via regex fallback" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render layout: "shared/wrapper" do %>Content<% end %>', + "app/views/shared/_wrapper.html.erb" => "
<%= yield %>
" + ) + + result = check(directory) + + assert_empty result.unused + end + + test "dynamic prefix marks matching partials as reachable" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "providers/#{provider_name}" %>', + "app/views/providers/_youtube.html.erb" => "
YouTube
", + "app/views/providers/_vimeo.html.erb" => "
Vimeo
" + ) + + result = check(directory) + + assert_empty result.unused + end + + test "dynamic render does not report unresolved" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "providers/#{name}" %>' + ) + + result = check(directory) + + assert_empty result.unresolved + end + + test "render call from Ruby controller marks partial as reachable" do + directory = create_project( + "app/views/pages/index.html.erb" => "

Page

", + "app/views/shared/_from_controller.html.erb" => "

From controller

", + "app/controllers/pages_controller.rb" => 'render partial: "shared/from_controller"' + ) + + result = check(directory) + + refute_includes result.unused.keys, "shared/from_controller" + end + + test "dynamic render from Ruby controller marks prefix partials as reachable" do + directory = create_project( + "app/views/pages/index.html.erb" => "

Page

", + "app/views/cards/_basic.html.erb" => "
Basic
", + "app/views/cards/_premium.html.erb" => "
Premium
", + "app/controllers/pages_controller.rb" => 'render partial: "cards/#{card_type}"' + ) + + result = check(directory) + + refute_includes result.unused.keys, "cards/basic" + refute_includes result.unused.keys, "cards/premium" + end + + test "render in non-output ERB tag is detected via regex fallback" do + directory = create_project( + "app/views/pages/index.html.erb" => '<% content = render("shared/widget") %><%= content %>', + "app/views/shared/_widget.html.erb" => "
Widget
" + ) + + result = check(directory) + + assert_empty result.unused + end + + test "circular render references do not cause infinite loop" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => '<%= render "shared/a" %>' + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "empty project has no issues" do + directory = create_project( + "app/views/pages/index.html.erb" => "

Hello

" + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "mixed resolved, unresolved, and unused" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %><%= render "shared/missing" %>', + "app/views/shared/_header.html.erb" => "

Header

", + "app/views/shared/_unused.html.erb" => "

Never used

" + ) + + result = check(directory) + + assert_equal 1, result.unresolved.count + assert_equal "shared/missing", result.unresolved.first[:partial] + assert_equal 1, result.unused.count + assert_includes result.unused.keys, "shared/unused" + end + + test "resolves partials with various extensions" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %><%= render "shared/b" %>', + "app/views/shared/_a.html.erb" => "

ERB

", + "app/views/shared/_b.turbo_stream.erb" => "B" + ) + + result = check(directory) + + assert_empty result.unresolved + assert_empty result.unused + end + + test "result tracks render call counts" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %><%= render "shared/b" %>', + "app/views/shared/_a.html.erb" => "

A

", + "app/views/shared/_b.html.erb" => "

B

" + ) + + result = check(directory) + + assert_equal 2, result.render_calls.count + assert_equal 2, result.partial_files.count + end + + test "result issues? returns false when clean" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/shared/_header.html.erb" => "

Header

" + ) + + result = check(directory) + + refute result.issues? + end + + test "result issues? returns true with unresolved" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/missing" %>' + ) + + result = check(directory) + + assert result.issues? + assert_equal 1, result.unresolved.count + assert_equal "shared/missing", result.unresolved.first[:partial] + assert_empty result.unused + end + + test "result issues? returns true with unused" do + directory = create_project( + "app/views/pages/index.html.erb" => "

Hello

", + "app/views/shared/_orphan.html.erb" => "

Orphan

" + ) + + result = check(directory) + + assert result.issues? + assert_empty result.unresolved + assert_equal 1, result.unused.count + assert_includes result.unused.keys, "shared/orphan" + end + + test "fully_resolvable? returns true when all partials resolve statically" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/shared/_header.html.erb" => '<%= render "shared/logo" %>', + "app/views/shared/_logo.html.erb" => "" + ) + + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + + assert analyzer.fully_resolvable?("app/views/pages/index.html.erb") + end + + test "fully_resolvable? returns false when a partial is missing" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', + "app/views/shared/_header.html.erb" => '<%= render "shared/missing" %>' + ) + + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + + refute analyzer.fully_resolvable?("app/views/pages/index.html.erb") + end + + test "fully_resolvable? returns false with dynamic render" do + directory = create_project( + "app/views/pages/index.html.erb" => "<%= render @product %>" + ) + + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + + refute analyzer.fully_resolvable?("app/views/pages/index.html.erb") + end + + test "fully_resolvable? returns false with interpolated partial path" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "cards/#{card_type}" %>' + ) + + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + + refute analyzer.fully_resolvable?("app/views/pages/index.html.erb") + end + + test "fully_resolvable? returns true for file with no render calls" do + directory = create_project( + "app/views/pages/index.html.erb" => "

Hello

" + ) + + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + + assert analyzer.fully_resolvable?("app/views/pages/index.html.erb") + end + + test "fully_resolvable? returns true for deeply nested chain that fully resolves" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => '<%= render "shared/c" %>', + "app/views/shared/_c.html.erb" => '<%= render "shared/d" %>', + "app/views/shared/_d.html.erb" => "

Leaf

" + ) + + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + + assert analyzer.fully_resolvable?("app/views/pages/index.html.erb") + end + + test "fully_resolvable? returns false when deeply nested partial is missing" do + directory = create_project( + "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', + "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', + "app/views/shared/_b.html.erb" => '<%= render "shared/c" %>', + "app/views/shared/_c.html.erb" => '<%= render "shared/missing" %>' + ) + + analyzer = Herb::ActionView::RenderAnalyzer.new(directory) + + refute analyzer.fully_resolvable?("app/views/pages/index.html.erb") + end +end diff --git a/test/render_checker_test.rb b/test/render_checker_test.rb deleted file mode 100644 index bb843c95b..000000000 --- a/test/render_checker_test.rb +++ /dev/null @@ -1,345 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" -require_relative "../lib/herb/render_checker" -require "tmpdir" -require "fileutils" - -class RenderCheckerTest < Minitest::Spec - def create_project(files = {}) - directory = Dir.mktmpdir("herb-render-check-") - - files.each do |path, content| - full_path = File.join(directory, path) - FileUtils.mkdir_p(File.dirname(full_path)) - File.write(full_path, content) - end - - directory - end - - def check(directory) - checker = Herb::RenderChecker.new(directory) - checker.analyze - end - - def teardown - @directories_to_clean&.each { |directory| FileUtils.rm_rf(directory) } - end - - def track_directory(directory) - @directories_to_clean ||= [] - @directories_to_clean << directory - directory - end - - test "no issues when all render calls resolve" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', - "app/views/shared/_header.html.erb" => "

Header

" - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - refute result.issues? - end - - test "detects unresolved render call" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/missing" %>' - )) - - result = check(directory) - - assert_equal 1, result.unresolved.count - assert_equal "shared/missing", result.unresolved.first[:partial] - end - - test "detects unused partial" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => "

Hello

", - "app/views/shared/_orphan.html.erb" => "

Orphan

" - )) - - result = check(directory) - - assert_equal 1, result.unused.count - assert_includes result.unused.keys, "shared/orphan" - end - - test "partial rendered from another partial is reachable" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', - "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', - "app/views/shared/_b.html.erb" => "

Leaf

" - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - end - - test "deep transitive chain is fully reachable" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', - "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', - "app/views/shared/_b.html.erb" => '<%= render "shared/c" %>', - "app/views/shared/_c.html.erb" => '<%= render "shared/d" %>', - "app/views/shared/_d.html.erb" => "

Deep leaf

" - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - end - - test "unreachable chain is entirely unused" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => "

No renders

", - "app/views/shared/_orphan.html.erb" => '<%= render "shared/orphan_child" %>', - "app/views/shared/_orphan_child.html.erb" => "

Child

" - )) - - result = check(directory) - - assert_equal 2, result.unused.count - assert_includes result.unused.keys, "shared/orphan" - assert_includes result.unused.keys, "shared/orphan_child" - end - - test "partial only rendered from unreachable partial is unused" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/used" %>', - "app/views/shared/_used.html.erb" => "

Used

", - "app/views/shared/_unused_parent.html.erb" => '<%= render "shared/unused_child" %>', - "app/views/shared/_unused_child.html.erb" => "

Child

" - )) - - result = check(directory) - - assert_equal 2, result.unused.count - assert_includes result.unused.keys, "shared/unused_parent" - assert_includes result.unused.keys, "shared/unused_child" - refute_includes result.unused.keys, "shared/used" - end - - test "multiple entry points can reach different partials" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', - "app/views/pages/about.html.erb" => '<%= render "shared/footer" %>', - "app/views/shared/_header.html.erb" => "

Header

", - "app/views/shared/_footer.html.erb" => "
Footer
" - )) - - result = check(directory) - - assert_empty result.unused - end - - test "partial reachable from one entry point but not another is still used" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/sidebar" %>', - "app/views/pages/about.html.erb" => "

No sidebar here

", - "app/views/shared/_sidebar.html.erb" => "" - )) - - result = check(directory) - - assert_empty result.unused - end - - test "resolves short partial name relative to calling file directory" do - directory = track_directory(create_project( - "app/views/posts/index.html.erb" => '<%= render "card" %>', - "app/views/posts/_card.html.erb" => "
Card
" - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - end - - test "resolves application/ fallback for short partial names" do - directory = track_directory(create_project( - "app/views/posts/index.html.erb" => '<%= render "toolbar" %>', - "app/views/application/_toolbar.html.erb" => "" - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - end - - test "layout render with block is detected via regex fallback" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render layout: "shared/wrapper" do %>Content<% end %>', - "app/views/shared/_wrapper.html.erb" => "
<%= yield %>
" - )) - - result = check(directory) - - assert_empty result.unused - end - - test "dynamic prefix marks matching partials as reachable" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "providers/#{provider_name}" %>', - "app/views/providers/_youtube.html.erb" => "
YouTube
", - "app/views/providers/_vimeo.html.erb" => "
Vimeo
" - )) - - result = check(directory) - - assert_empty result.unused - end - - test "dynamic render does not report unresolved" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "providers/#{name}" %>' - )) - - result = check(directory) - - assert_empty result.unresolved - end - - test "render call from Ruby controller marks partial as reachable" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => "

Page

", - "app/views/shared/_from_controller.html.erb" => "

From controller

", - "app/controllers/pages_controller.rb" => 'render partial: "shared/from_controller"' - )) - - result = check(directory) - - refute_includes result.unused.keys, "shared/from_controller" - end - - test "dynamic render from Ruby controller marks prefix partials as reachable" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => "

Page

", - "app/views/cards/_basic.html.erb" => "
Basic
", - "app/views/cards/_premium.html.erb" => "
Premium
", - "app/controllers/pages_controller.rb" => 'render partial: "cards/#{card_type}"' - )) - - result = check(directory) - - refute_includes result.unused.keys, "cards/basic" - refute_includes result.unused.keys, "cards/premium" - end - - test "render in non-output ERB tag is detected via regex fallback" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<% content = render("shared/widget") %><%= content %>', - "app/views/shared/_widget.html.erb" => "
Widget
" - )) - - result = check(directory) - - assert_empty result.unused - end - - test "circular render references do not cause infinite loop" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/a" %>', - "app/views/shared/_a.html.erb" => '<%= render "shared/b" %>', - "app/views/shared/_b.html.erb" => '<%= render "shared/a" %>' - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - end - - test "empty project has no issues" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => "

Hello

" - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - end - - test "mixed resolved, unresolved, and unused" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/header" %><%= render "shared/missing" %>', - "app/views/shared/_header.html.erb" => "

Header

", - "app/views/shared/_unused.html.erb" => "

Never used

" - )) - - result = check(directory) - - assert_equal 1, result.unresolved.count - assert_equal "shared/missing", result.unresolved.first[:partial] - assert_equal 1, result.unused.count - assert_includes result.unused.keys, "shared/unused" - end - - test "resolves partials with various extensions" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/a" %><%= render "shared/b" %>', - "app/views/shared/_a.html.erb" => "

ERB

", - "app/views/shared/_b.turbo_stream.erb" => "B" - )) - - result = check(directory) - - assert_empty result.unresolved - assert_empty result.unused - end - - test "result tracks render call counts" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/a" %><%= render "shared/b" %>', - "app/views/shared/_a.html.erb" => "

A

", - "app/views/shared/_b.html.erb" => "

B

" - )) - - result = check(directory) - - assert_equal 2, result.render_calls.count - assert_equal 2, result.partial_files.count - end - - test "result issues? returns false when clean" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/header" %>', - "app/views/shared/_header.html.erb" => "

Header

" - )) - - result = check(directory) - - refute result.issues? - end - - test "result issues? returns true with unresolved" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => '<%= render "shared/missing" %>' - )) - - result = check(directory) - - assert result.issues? - end - - test "result issues? returns true with unused" do - directory = track_directory(create_project( - "app/views/pages/index.html.erb" => "

Hello

", - "app/views/shared/_orphan.html.erb" => "

Orphan

" - )) - - result = check(directory) - - assert result.issues? - end -end