Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
576 changes: 576 additions & 0 deletions RECORDER_IMPLEMENTATION_PLAN.md

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions bin/liquid-record
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env ruby

require_relative '../lib/liquid'
require_relative '../performance/theme_runner'

def usage
puts <<~USAGE
Usage: liquid-record [options] <output_file> <theme_name> [template_name]

Records liquid template execution to a JSON file for hermetic replay.

Arguments:
output_file - Path to save the recording JSON file
theme_name - Name of theme in performance/tests/ (e.g., vogue, tribble)
template_name - Specific template to record (optional, defaults to 'index')

Options:
--verify - After recording, replay and verify output matches (shows diff if different)

Examples:
liquid-record recording.json vogue
liquid-record product.json vogue product
liquid-record --verify product.json vogue product
USAGE
end

def main
# Parse options
verify_mode = false
args = ARGV.dup

if args.include?('--verify')
verify_mode = true
args.delete('--verify')
end

if args.length < 2 || args.length > 3
usage
exit 1
end

output_file = args[0]
theme_name = args[1]
template_name = args[2] || 'index'

test_name = "#{theme_name}/#{template_name}.liquid"

puts "Recording template execution: #{test_name}"
puts "Output file: #{output_file}"
STDOUT.flush

begin
original_output = nil
Liquid::TemplateRecorder.record(output_file) do
# Use ThemeRunner to get realistic data, but parse template during recording
theme_runner = ThemeRunner.new
test = theme_runner.find_test(test_name)
compiled = theme_runner.send(:compile_test, test)

# Parse the template within the recording context so our wrapper can capture it
template = Liquid::Template.parse(test[:liquid])
original_output = template.render(compiled[:assigns])
original_output
end

puts "Recording completed successfully!"
puts "File size: #{File.size(output_file)} bytes"
STDOUT.flush

# Run verification if requested
if verify_mode
puts
puts "Running verification..."
STDOUT.flush
verify_recording(output_file, original_output)
end
rescue => e
puts "Error during recording: #{e.message}"
puts e.backtrace.first(5)
exit 1
end
end

def verify_recording(recording_file, expected_output)
begin
# Load and create replayer
require_relative '../lib/liquid/template_recorder/replayer'
replayer = Liquid::TemplateRecorder.replay_from(recording_file, mode: :compute)

# Get replayed output
replayed_output = replayer.render

if expected_output == replayed_output
puts "✅ Verification PASSED - outputs match perfectly!"
puts "Output length: #{expected_output.length} characters"
else
puts "❌ Verification FAILED - outputs differ"
puts "Expected length: #{expected_output.length}"
puts "Actual length: #{replayed_output.length}"

# Write outputs to temp files for diff
require 'tempfile'
expected_file = Tempfile.new(['expected', '.txt'])
actual_file = Tempfile.new(['actual', '.txt'])

expected_file.write(expected_output)
expected_file.flush

actual_file.write(replayed_output)
actual_file.flush

puts
puts "Showing diff (expected vs actual):"
puts "=" * 50
system("diff -u #{expected_file.path} #{actual_file.path}")

expected_file.close
actual_file.close

exit 1
end
rescue => e
puts "❌ Verification ERROR: #{e.message}"
puts e.backtrace.first(3)
exit 1
end
end

main if __FILE__ == $0
67 changes: 67 additions & 0 deletions bin/liquid-replay
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env ruby

require_relative '../lib/liquid'

def usage
puts <<~USAGE
Usage: liquid-replay <recording_file> [mode]

Replays a liquid template recording in hermetic mode.

Arguments:
recording_file - Path to the JSON recording file
mode - Replay mode: compute (default), strict, or verify

Modes:
compute - Normal replay with computed results
strict - Strict replay, errors on unexpected calls
verify - Verification mode, compares against recorded results

Examples:
liquid-replay recording.json
liquid-replay recording.json strict
liquid-replay recording.json verify
USAGE
end

def main
if ARGV.length < 1 || ARGV.length > 2
usage
exit 1
end

recording_file = ARGV[0]
mode = (ARGV[1] || 'compute').to_sym

unless File.exist?(recording_file)
puts "Error: Recording file '#{recording_file}' not found"
exit 1
end

unless [:compute, :strict, :verify].include?(mode)
puts "Error: Invalid mode '#{mode}'. Must be compute, strict, or verify"
exit 1
end

puts "Replaying recording: #{recording_file}"
puts "Mode: #{mode}"

begin
replayer = Liquid::TemplateRecorder.replay_from(recording_file, mode: mode)
result = replayer.render

puts "\nReplay completed successfully!"
puts "Output length: #{result.length} characters"
puts "\nRendered output:"
puts "-" * 40
puts result
puts "-" * 40
STDOUT.flush
rescue => e
puts "Error during replay: #{e.message}"
puts e.backtrace.first(5)
exit 1
end
end

main if __FILE__ == $0
1 change: 1 addition & 0 deletions lib/liquid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,4 @@ module Liquid
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
require 'liquid/template_recorder'
9 changes: 8 additions & 1 deletion lib/liquid/drop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,18 @@ def liquid_method_missing(method)

# called by liquid to invoke a drop
def invoke_drop(method_or_key)
if self.class.invokable?(method_or_key)
result = if self.class.invokable?(method_or_key)
send(method_or_key)
else
liquid_method_missing(method_or_key)
end

# Recording hook - only active when recorder present
if @context && (recorder = @context.registers[:recorder])
recorder.emit_drop_read(self, method_or_key, result)
end

result
end

def key?(_name)
Expand Down
20 changes: 17 additions & 3 deletions lib/liquid/file_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ module Liquid
# This will parse the template with a LocalFileSystem implementation rooted at 'template_path'.
class BlankFileSystem
# Called by Liquid to retrieve a template file
def read_template_file(_template_path)
def read_template_file(_template_path, context: nil)
raise FileSystemError, "This liquid context does not allow includes."
end
end
Expand Down Expand Up @@ -51,11 +51,25 @@ def initialize(root, pattern = "_%s.liquid")
@pattern = pattern
end

def read_template_file(template_path)
def read_template_file(template_path, context: nil)
full_path = full_path(template_path)
raise FileSystemError, "No such template '#{template_path}'" unless File.exist?(full_path)

File.read(full_path)
content = File.read(full_path)

# Recording hook - try context registers first, fall back to thread-local
recorder = nil
if context&.registers
recorder = context.registers[:recorder]
else
recorder = Thread.current[:liquid_recorder]
end

if recorder
recorder.emit_file_read(template_path, content)
end

content
end

def full_path(template_path)
Expand Down
2 changes: 1 addition & 1 deletion lib/liquid/partial_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def self.load(template_name, context:, parse_context:)
return cached if cached

file_system = context.registers[:file_system]
source = file_system.read_template_file(template_name)
source = file_system.read_template_file(template_name, context: context)

parse_context.partial = true

Expand Down
11 changes: 9 additions & 2 deletions lib/liquid/strainer_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,20 @@ def filter_methods
end

def invoke(method, *args)
if self.class.invokable?(method)
result = if self.class.invokable?(method)
send(method, *args)
elsif @context.strict_filters
elsif @context && @context.strict_filters
raise Liquid::UndefinedFilter, "undefined filter #{method}"
else
args.first
end

# Recording hook - only when recorder is present
if @context && @context.registers && (recorder = @context.registers[:recorder])
recorder.emit_filter_call(method, args.first, args[1..-1] || [], result)
end

result
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message, e.backtrace
end
Expand Down
17 changes: 16 additions & 1 deletion lib/liquid/tags/for.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ def collection_segment(context)
end

def render_segment(context, output, segment)
# Recording hook - loop enter
if (recorder = context.registers[:recorder])
recorder.for_enter(@collection_name.name, @variable_name)
end

for_stack = context.registers[:for_stack] ||= []
length = segment.length

Expand All @@ -155,7 +160,12 @@ def render_segment(context, output, segment)
begin
context['forloop'] = loop_vars

segment.each do |item|
segment.each_with_index do |item, index|
# Recording hook - item binding
if (recorder = context.registers[:recorder])
recorder.for_item(index, item)
end
Comment on lines +163 to +167
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
segment.each_with_index do |item, index|
# Recording hook - item binding
if (recorder = context.registers[:recorder])
recorder.for_item(index, item)
end
recorder = context.registers[:recorder]
segment.each_with_index do |item, index|
# Recording hook - item binding
recorder.for_item(index, item) if recorder


context[@variable_name] = item
@for_block.render_to_output_buffer(context, output)
loop_vars.send(:increment!)
Expand All @@ -168,6 +178,11 @@ def render_segment(context, output, segment)
end
ensure
for_stack.pop

# Recording hook - loop exit
if (recorder = context.registers[:recorder])
recorder.for_exit
end
Comment on lines +181 to +185
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Recording hook - loop exit
if (recorder = context.registers[:recorder])
recorder.for_exit
end
# Recording hook - loop exit
recorder.for_exit if recorder

end
end

Expand Down
Loading
Loading