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"), "
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, **) + Herb::Engine.new( + template, + filename: "app/views/posts/show.html.erb", + project_path: @project_path, + ** + ) + 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 "render validator is not run during normal compilation" 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 + + 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" => "" + ) + + 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