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.rb b/lib/herb.rb
index 3d76e455c..af0081979 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"
@@ -21,6 +29,7 @@
require_relative "herb/ast/nodes"
require_relative "herb/ast/erb_content_node"
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/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
new file mode 100644
index 000000000..5718e2b71
--- /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?
+ keywords&.partial && !keywords&.partial&.value&.empty?
+ end
+
+ def dynamic?
+ !static_partial? && (keywords&.object || keywords&.renderable)
+ end
+
+ def partial_path
+ keywords&.partial&.value
+ end
+
+ def template_name
+ keywords&.template_path&.value
+ end
+
+ def layout_name
+ keywords&.layout&.value
+ end
+
+ def local_names
+ keywords&.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 = [] #: Array[Pathname]
+
+ 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 = [] #: Array[String]
+
+ 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 = [] #: Array[String]
+
+ 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 8a7105cd1..588c26b14 100644
--- a/lib/herb/cli.rb
+++ b/lib/herb/cli.rb
@@ -90,28 +90,29 @@ 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 dev [path] Start the dev server and watch for file changes.
- bundle exec herb dev stop Stop the running dev server.
- bundle exec herb dev restart Restart the dev server.
- bundle exec herb dev status Show dev server status.
- bundle exec herb diff [old] [new] Diff two files and show the minimal set of AST differences.
- 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 diff [old] [new] Diff two files and show the minimal set of AST differences.
+ bundle exec herb playground [file] Open the content of the source file in the playground.
+ bundle exec herb dev Start the dev server and watch for file changes.
+ 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 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:
@@ -211,6 +212,8 @@ def result
@file = @args[1]
run_dev_server
end
+ when "actionview"
+ run_actionview_command
when "diff"
diff_files
when "lint"
@@ -362,6 +365,143 @@ def find_node_binary(name)
nil
end
+ 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 "action_view/render_analyzer"
+
+ path = File.expand_path(@file || ".")
+
+ unless File.directory?(path)
+ puts "Not a directory: '#{path}'."
+ exit(1)
+ end
+
+ analyzer = Herb::ActionView::RenderAnalyzer.new(path)
+ has_issues = analyzer.check!
+
+ exit(has_issues ? 1 : 0)
+ when "graph"
+ require_relative "action_view/render_analyzer"
+
+ path = @file || "."
+
+ unless File.directory?(path) || File.file?(path)
+ puts "Not a file or directory: '#{path}'."
+ exit(1)
+ end
+
+ 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)
+ analyzer.graph_file!(path)
+ else
+ analyzer.graph!
+ end
+
+ exit(0)
+ 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
+ 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 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
diff --git a/lib/herb/engine.rb b/lib/herb/engine.rb
index f4ab3b4e5..db193a916 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/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
new file mode 100644
index 000000000..9cd968abf
--- /dev/null
+++ b/test/engine/render_nodes_test.rb
@@ -0,0 +1,158 @@
+# 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"), "
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" => "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" => "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" => "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" => "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" => "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" => "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" => "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