diff --git a/.rubocop.yml b/.rubocop.yml index 793a7639e..b20494689 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,6 +12,12 @@ Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes +Style/MixinUsage: + Exclude: + - bin/compare + - bin/compare-render + - bin/compare-compile + Style/ClassAndModuleChildren: Enabled: false @@ -94,6 +100,7 @@ Metrics/ModuleLength: Exclude: - test/**/*.rb - templates/**/*.rb + - bin/lib/compare_helpers.rb Metrics/BlockLength: Max: 30 @@ -160,6 +167,8 @@ Security/Eval: - lib/herb/cli.rb - test/**/*.rb - bin/erubi-render + - bin/compare-render + - bin/compare-render Security/MarshalLoad: Exclude: diff --git a/Gemfile b/Gemfile index cce0da4bc..87da51e09 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,8 @@ gemspec gem "prism", github: "ruby/prism", tag: "v1.6.0" gem "actionview", "~> 8.0" +gem "difftastic", "~> 0.7" +gem "erubi" gem "lz_string" gem "maxitest" gem "minitest-difftastic", "~> 0.2" diff --git a/Gemfile.lock b/Gemfile.lock index 8897f77d3..06032489a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -183,6 +183,8 @@ PLATFORMS DEPENDENCIES actionview (~> 8.0) + difftastic (~> 0.7) + erubi herb! lz_string maxitest diff --git a/bin/compare b/bin/compare new file mode 100755 index 000000000..f32cd97ed --- /dev/null +++ b/bin/compare @@ -0,0 +1,100 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "pathname" +require "optparse" + +require_relative "lib/compare_helpers" + +include CompareHelpers + +def usage + puts "Usage: #{$PROGRAM_NAME} [options] " + puts "" + puts "Compares an ERB template with both Herb and Erubi (compile and render)." + puts "" + puts "Options:" + puts " --no-escape Disable HTML escaping" + puts " --escape Enable HTML escaping (default: false)" + puts " -h, --help Show this help message" + exit(1) +end + +options = {} +options[:escape] = false + +OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] " + + opts.on("--no-escape", "Disable HTML escaping") do + options[:escape] = false + end + + opts.on("--escape", "Enable HTML escaping") do + options[:escape] = true + end + + opts.on("-h", "--help", "Show this help message") do + usage + end +end.parse! + +file_path = ARGV[0] + +unless file_path + puts "Error: No file specified" + usage +end + +unless File.exist?(file_path) + puts "Error: File '#{file_path}' not found" + exit(1) +end + +bin_dir = File.expand_path(__dir__) + +template = File.read(file_path) +show_template(file_path, template) + +args = [] +args << "--escape" if options[:escape] +args << "--no-escape" unless options[:escape] +args << "--no-template" +args << file_path + +box_header("Compiled HTML+ERB Template Output") +puts "" + +compile_result = system(File.join(bin_dir, "compare-compile"), *args) + +puts "" +box_header("Rendered HTML+ERB Template Output") +puts "" + +render_result = system(File.join(bin_dir, "compare-render"), *args) + +puts "" +box_header("Summary") + +if render_result && compile_result + puts "✓ All comparisons passed!" + puts "" + puts " • Rendered outputs match" + puts " • Compiled sources match" + exit(0) +elsif render_result + puts "⚠ Rendered outputs match, but compiled sources differ" + puts "" + puts " • ✓ Rendered outputs match (what matters!)" + puts " • ✗ Compiled sources differ (different formatting is OK)" + puts "" + puts "This is usually fine. Herb and Erubi format generated code differently," + puts "but produce the same final output." + exit(0) +else + puts "✗ Comparisons failed" + puts "" + puts " • Rendered output: #{render_result ? "✓ Match" : "✗ Differ"}" + puts " • Compiled source: #{compile_result ? "✓ Match" : "✗ Differ"}" + exit(1) +end diff --git a/bin/compare-compile b/bin/compare-compile new file mode 100755 index 000000000..062e64764 --- /dev/null +++ b/bin/compare-compile @@ -0,0 +1,89 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "pathname" +require "optparse" +require_relative "lib/compare_helpers" + +include CompareHelpers + +def usage + puts "Usage: #{$PROGRAM_NAME} [options] " + puts "" + puts "Compiles an ERB template with both Erubi::Engine and Herb::Engine and shows the diff." + puts "" + puts "Options:" + puts " --no-escape Disable HTML escaping" + puts " --escape Enable HTML escaping (default: false)" + puts " -h, --help Show this help message" + exit(1) +end + +options = {} +options[:escape] = false + +OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] " + parse_common_options(opts, options) { usage } +end.parse! + +options[:show_template] = true unless options.key?(:show_template) + +file_path = ARGV[0] + +unless file_path + puts "Error: No file specified" + usage +end + +unless File.exist?(file_path) + puts "Error: File '#{file_path}' not found" + exit(1) +end + +load_dependencies + +template = File.read(file_path) +show_template(file_path, template) if options[:show_template] + +begin + herb_engine = Herb::Engine.new(template, options) + herb_src = herb_engine.src +rescue StandardError => e + puts "Herb compile error: #{e.message}" + exit(1) +end + +begin + erubi_engine = Erubi::Engine.new(template, options) + erubi_src = erubi_engine.src +rescue StandardError => e + puts "Erubi compile error: #{e.message}" + exit(1) +end + +herb_normalized = herb_src.gsub("__herb", "__engine") +erubi_normalized = erubi_src.gsub("__erubi", "__engine") + +if herb_normalized == erubi_normalized + puts "✓ Compiled sources match (after normalization)!" + puts "" + puts "Herb output:" + puts herb_src + exit(0) +elsif herb_src == erubi_src + puts "✓ Compiled sources match exactly!" + puts "" + puts "Output:" + puts herb_src + exit(0) +else + puts "✗ Compiled sources differ!" + puts "" + puts "" + + diff_output = diff_compiled_sources(erubi_src, herb_src) + puts diff_output if diff_output + + exit(1) +end diff --git a/bin/compare-render b/bin/compare-render new file mode 100755 index 000000000..c5b1c546a --- /dev/null +++ b/bin/compare-render @@ -0,0 +1,80 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "pathname" +require "optparse" +require_relative "lib/compare_helpers" + +include CompareHelpers + +def usage + puts "Usage: #{$PROGRAM_NAME} [options] " + puts "" + puts "Renders an ERB template with both Erubi::Engine and Herb::Engine and shows the diff." + puts "" + puts "Options:" + puts " --no-escape Disable HTML escaping" + puts " --escape Enable HTML escaping (default: false)" + puts " -h, --help Show this help message" + exit(1) +end + +options = {} +options[:escape] = false + +OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] " + parse_common_options(opts, options) { usage } +end.parse! + +options[:show_template] = true unless options.key?(:show_template) + +file_path = ARGV[0] + +unless file_path + puts "Error: No file specified" + usage +end + +unless File.exist?(file_path) + puts "Error: File '#{file_path}' not found" + exit(1) +end + +load_dependencies + +template = File.read(file_path) +show_template(file_path, template) if options[:show_template] + +begin + herb_engine = Herb::Engine.new(template, options) + herb_output = eval(herb_engine.src) +rescue StandardError => e + puts "Herb render error: #{e.message}" + exit(1) +end + +begin + erubi_engine = Erubi::Engine.new(template, options) + erubi_output = eval(erubi_engine.src) +rescue StandardError => e + puts "Erubi render error: #{e.message}" + exit(1) +end + +if herb_output == erubi_output + puts "✓ Outputs match!" + puts "" + puts "Output:" + puts herb_output + exit(0) +else + puts "✗ Outputs differ!" + puts "" + puts "" + + diff_output = diff_rendered_outputs(erubi_output, herb_output) + puts diff_output if diff_output + + exit(1) +end diff --git a/bin/lib/compare_helpers.rb b/bin/lib/compare_helpers.rb new file mode 100644 index 000000000..47bcacfec --- /dev/null +++ b/bin/lib/compare_helpers.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module CompareHelpers + def box_header(title, width = 80) + padding = width - title.length - 2 + left_pad = padding / 2 + right_pad = padding - left_pad + + puts "╔#{"═" * (width - 2)}╗" + puts "║#{" " * left_pad}#{title}#{" " * right_pad}║" + puts "╚#{"═" * (width - 2)}╝" + end + + def box_with_content(title, content, width = 80) + padding = width - title.length - 2 + left_pad = padding / 2 + right_pad = padding - left_pad + + puts "╔#{"═" * (width - 2)}╗" + puts "║#{" " * left_pad}#{title}#{" " * right_pad}║" + puts "╠#{"═" * (width - 2)}╣" + puts content + puts "╚#{"═" * (width - 2)}╝" + end + + def section_header(title, width = 80) + puts "" + puts "" + puts "#{title}:" + puts "─" * width + end + + def show_template(file_path, template, width = 80) + puts "Template: #{file_path}" + puts "─" * width + puts template + puts "─" * width + puts "" + end + + def load_dependencies + $LOAD_PATH.unshift File.expand_path("../../lib", __dir__) + require "herb" + + begin + original_verbose = $VERBOSE + $VERBOSE = nil + require "erubi" + rescue LoadError + puts "Error: Erubi gem is not available." + puts "Install with: gem install erubi" + exit(1) + ensure + $VERBOSE = original_verbose + end + + begin + require "difftastic" + rescue LoadError + puts "Error: Difftastic gem is not available." + puts "Install with: gem install difftastic" + exit(1) + end + end + + def parse_common_options(opts, options) + opts.on("--no-escape", "Disable HTML escaping") do + options[:escape] = false + end + + opts.on("--escape", "Enable HTML escaping") do + options[:escape] = true + end + + opts.on("--no-template", "Don't show the input template") do + options[:show_template] = false + end + + opts.on("-h", "--help", "Show this help message") do + yield if block_given? + end + end + + def require_erubi_silently + original_verbose = $VERBOSE + $VERBOSE = nil + require "erubi" + ensure + $VERBOSE = original_verbose + end + + def diff_compiled_sources(erubi_src, herb_src) + return nil if erubi_src == herb_src + + output = "" + output += "String Comparison (including formatting differences):\n" + output += "#{"─" * 80}\n" + + string_diff = Difftastic::Differ.new( + color: :always, + left_label: "Erubi::Engine compiled", + right_label: "Herb::Engine compiled" + ).diff_strings(erubi_src, herb_src) + + output += string_diff + + if !string_diff.strip.empty? && !string_diff.include?("No changes.") + output += "\n\n" + output += "Ruby AST Comparison (semantic differences only):\n" + output += "#{"─" * 80}\n" + + begin + ast_diff = Difftastic::Differ.new( + color: :always, + left_label: "Erubi::Engine compiled", + right_label: "Herb::Engine compiled" + ).diff_ruby(erubi_src, herb_src) + + output += if ast_diff.strip.empty? || ast_diff.include?("No changes.") + "✓ ASTs are identical (only formatting differs)" + else + ast_diff + end + rescue StandardError => e + output += "Could not parse as Ruby: #{e.message}" + end + end + + output + end + + def diff_rendered_outputs(erubi_output, herb_output) + return nil if erubi_output == herb_output + + output = "" + output += "String Comparison:\n" + output += "#{"─" * 80}\n" + + string_diff = Difftastic::Differ.new( + color: :always, + left_label: "Erubi::Engine output", + right_label: "Herb::Engine output" + ).diff_strings(erubi_output, herb_output) + + output += string_diff + + if !string_diff.strip.empty? && !string_diff.include?("No changes.") + output += "\n\n" + output += "HTML Semantic Comparison:\n" + output += "#{"─" * 80}\n" + + begin + html_diff = Difftastic::Differ.new( + color: :always, + left_label: "Erubi::Engine output", + right_label: "Herb::Engine output" + ).diff_html(erubi_output, herb_output) + + output += if html_diff.strip.empty? || html_diff.include?("No changes.") + "✓ HTML semantics are identical (only formatting/whitespace differs)" + else + html_diff + end + rescue StandardError => e + output += "Could not parse as HTML: #{e.message}" + end + end + + output + end +end diff --git a/test/snapshot_utils.rb b/test/snapshot_utils.rb index a6cb545fe..562df24ec 100644 --- a/test/snapshot_utils.rb +++ b/test/snapshot_utils.rb @@ -3,12 +3,15 @@ require "fileutils" require "readline" require "digest" +require_relative "../bin/lib/compare_helpers" def ask?(prompt = "") Readline.readline("===> #{prompt}? (y/N) ", true).squeeze(" ").strip == "y" end module SnapshotUtils + include CompareHelpers + def assert_lexed_snapshot(source) result = Herb.lex(source) expected = result.value.inspect @@ -31,22 +34,32 @@ def assert_parsed_snapshot(source, **options) result end - def assert_compiled_snapshot(source, options = {}) + def assert_compiled_snapshot(source, options = {}, **kwargs) require_relative "../lib/herb/engine" - engine = Herb::Engine.new(source, options) + enforce_erubi_equality = kwargs.delete(:enforce_erubi_equality) || false + engine_options = options.merge(kwargs) + + engine = Herb::Engine.new(source, engine_options) expected = engine.src - snapshot_key = { source: source, options: options }.to_s + snapshot_key = { source: source, options: engine_options }.to_s assert_snapshot_matches(expected, snapshot_key) + if should_compare_with_erubi? || enforce_erubi_equality + compare_with_erubi_compiled(source, engine.src, engine_options, enforce_erubi_equality) + end + engine end - def assert_evaluated_snapshot(source, locals = {}, options = {}) + def assert_evaluated_snapshot(source, locals = {}, options = {}, **kwargs) require_relative "../lib/herb/engine" - engine = Herb::Engine.new(source, options) + enforce_erubi_equality = kwargs.delete(:enforce_erubi_equality) || false + engine_options = options.merge(kwargs) + + engine = Herb::Engine.new(source, engine_options) binding_context = Object.new locals.each do |key, value| @@ -58,11 +71,15 @@ def assert_evaluated_snapshot(source, locals = {}, options = {}) snapshot_key = { source: source, locals: locals, - options: options, + options: engine_options, }.to_s assert_snapshot_matches(result, snapshot_key) + if should_compare_with_erubi? || enforce_erubi_equality + compare_with_erubi_evaluated(source, result, locals, engine_options, enforce_erubi_equality) + end + { engine: engine, result: result } end @@ -176,6 +193,79 @@ def snapshot_file(source, options = {}) expected_snapshot_path end + def should_compare_with_erubi? + return false if class_name.include?("DebugMode") + + !ENV["COMPARE_WITH_ERUBI"].nil? + end + + def compare_with_erubi_compiled(source, herb_src, options, enforce_equality: false) + require_erubi_silently + + begin + erubi_engine = Erubi::Engine.new(source, options) + erubi_src = erubi_engine.src + + diff_output = diff_compiled_sources(erubi_src, herb_src) + return unless diff_output + + message = "\n#{"=" * 80}\n" + message += "WARNING: Herb compiled output differs from Erubi\n" + message += "#{"=" * 80}\n" + message += "Test: #{class_name} #{name}\n" + message += "\nTemplate:\n#{source.inspect}\n" + message += "\n" + message += diff_output + message += "\n" + message += "#{"=" * 80}\n" + + if ENV["FAIL_ON_ERUBI_MISMATCH"] || enforce_equality + flunk(message) + else + puts message + end + rescue StandardError + nil + end + end + + def compare_with_erubi_evaluated(source, herb_result, locals, options, enforce_equality: false) + require_erubi_silently + + begin + erubi_engine = Erubi::Engine.new(source, options) + binding_context = Object.new + + locals.each do |key, value| + binding_context.define_singleton_method(key) { value } + end + + erubi_result = binding_context.instance_eval(erubi_engine.src) + + diff_output = diff_rendered_outputs(erubi_result, herb_result) + return unless diff_output + + message = "\n#{"=" * 80}\n" + message += "WARNING: Herb evaluated output differs from Erubi\n" + message += "#{"=" * 80}\n" + message += "Test: #{class_name} #{name}\n" + message += "\nTemplate:\n#{source.inspect}\n" + message += "\nLocals: #{locals.inspect}\n" + message += "\n" + message += diff_output + message += "\n" + message += "#{"=" * 80}\n" + + if ENV["FAIL_ON_ERUBI_MISMATCH"] || enforce_equality + flunk(message) + else + puts message + end + rescue StandardError + nil + end + end + private def sanitize_name_for_filesystem(name)