Files
liquid/lib/liquid/compile/ruby_compiler.rb
T
Tobi Lutke 652b9c0897 Add to_ruby convention for custom tag compilation
Tags can now implement to_ruby(code, compiler) to provide their own
optimized Ruby code generation. This allows:

1. Third-party tags to participate in compilation
2. Shopify-specific tags to be lowered to Ruby
3. Better performance for frequently-used custom tags

Priority order for tag compilation:
1. tag.to_ruby(code, compiler) - Custom implementation
2. Built-in compiler (IfCompiler, ForCompiler, etc.)
3. Yield to caller as external tag

Also updated PROJECT.md with:
- External calls yielding documentation
- to_ruby convention documentation
2025-12-31 12:33:14 -04:00

393 lines
14 KiB
Ruby

# frozen_string_literal: true
module Liquid
module Compile
# RubyCompiler transforms a parsed Liquid template into pure Ruby code.
#
# The compiled code is a string that can be eval'd to create a proc/lambda
# that takes an assigns hash and returns the rendered output string.
#
# ## Optimization Opportunities
#
# The compiled Ruby code has several significant advantages over interpreted Liquid:
#
# 1. **No Context Object**: Instead of using a Context for variable lookups,
# variables are extracted directly from the assigns hash and stored in
# local Ruby variables. This eliminates hash lookups on every access.
#
# 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby
# method calls rather than going through context.invoke().
#
# 3. **No Resource Limits Tracking**: The compiled code doesn't track render
# scores, write scores, or assign scores, eliminating per-node overhead.
#
# 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead
# of manually managing scope stacks with push/pop operations.
#
# 5. **Direct String Concatenation**: Output is built with direct << operations
# rather than through render_to_output_buffer abstractions.
#
# 6. **Native Control Flow**: break/continue become Ruby's break/next,
# eliminating interrupt objects and checks.
#
# 7. **No to_liquid Calls**: Values are used directly without conversion.
#
# 8. **Potential for Constant Folding**: Expressions with only literals
# could be evaluated at compile time (future enhancement).
#
# 9. **Potential for Dead Code Elimination**: Unreachable branches like
# `{% if false %}` could be removed (future enhancement).
#
# 10. **No Profiling Hooks**: No profiler overhead in the generated code.
#
# 11. **No Exception Rendering**: Errors propagate naturally rather than
# being caught and rendered inline.
#
# ## Usage
#
# template = Liquid::Template.parse("Hello, {{ name }}!")
# ruby_code = template.compile_to_ruby
# render_proc = eval(ruby_code)
# result = render_proc.call({ "name" => "World" })
# # => "Hello, World!"
#
class RubyCompiler
attr_reader :template, :options
# @param template [Liquid::Template] The parsed template to compile
# @param options [Hash] Compilation options
# @option options [Boolean] :strict_variables Raise on undefined variables (default: false)
# @option options [Boolean] :include_filters Include filter helper methods (default: true)
# @option options [Boolean] :debug Emit source comments for debugging (default: false)
# @option options [Object] :file_system File system for loading partials (default: template's environment)
def initialize(template, options = {})
@template = template
@options = {
strict_variables: false,
include_filters: true,
debug: false,
file_system: nil,
}.merge(options)
@var_counter = 0
@partials = {} # Registered partials: name => method_name
@partial_sources = {} # Partial sources: name => source code
@partial_counter = 0
@external_tags = {} # External tags: var_name => tag object
@external_tag_counter = 0
@has_external_filters = false # Whether we need the filter helper
end
# Mark that we have external filters
def register_external_filter
@has_external_filters = true
end
# Check if external filters are used
def has_external_filters?
@has_external_filters
end
# Register an external tag that will be called at runtime
# @param tag [Liquid::Tag] The tag to register
# @return [String] The variable name for this tag
def register_external_tag(tag)
@external_tag_counter += 1
var_name = "__ext_tag_#{@external_tag_counter}__"
@external_tags[var_name] = tag
var_name
end
# Get all registered external tags
# @return [Hash] Map of variable names to tag objects
def external_tags
@external_tags
end
# Get the file system for loading partials
def file_system
@options[:file_system] || @template.instance_variable_get(:@environment)&.file_system
end
# Load a partial source from the file system
# @param name [String] The partial name
# @return [String, nil] The partial source or nil if not found
def load_partial(name)
return @partial_sources[name] if @partial_sources.key?(name)
fs = file_system
return nil unless fs && fs.respond_to?(:read_template_file)
begin
source = fs.read_template_file(name)
@partial_sources[name] = source
source
rescue Liquid::FileSystemError
nil
end
end
# Register a partial and return its method name
# @param name [String] The partial name
# @param source [String] The partial source
# @return [String] The method name for this partial
def register_partial(name, source)
return @partials[name] if @partials.key?(name)
@partial_counter += 1
method_name = "__partial_#{@partial_counter}__"
@partials[name] = method_name
method_name
end
# Get all registered partials
def registered_partials
@partials
end
# Check if debug mode is enabled
def debug?
@options[:debug]
end
# Emit a debug comment with source location info
# This creates a lightweight source map that allows tracing errors
# back to the original Liquid source
def emit_debug_comment(code, node, description = nil)
return unless debug?
line_number = node.respond_to?(:line_number) ? node.line_number : nil
raw_markup = extract_raw_markup(node)
comment_parts = []
comment_parts << "LIQUID"
comment_parts << "L#{line_number}" if line_number
comment_parts << description if description
comment_parts << raw_markup.inspect if raw_markup && raw_markup.length < 80
code.line "# #{comment_parts.join(' | ')}"
end
# Extract the raw markup from a node for debug output
def extract_raw_markup(node)
case node
when Variable
"{{ #{node.raw} }}"
when Tag
if node.respond_to?(:raw)
"{% #{node.tag_name} #{node.raw} %}"
elsif node.respond_to?(:markup)
"{% #{node.tag_name} #{node.markup} %}"
else
"{% #{node.class.name.split('::').last.downcase} %}"
end
else
nil
end
end
# Compile the template to a Ruby code string
# @return [String] Ruby code that can be eval'd to create a render proc
# @return [Hash] If external tags are used, returns { code: String, external_tags: Hash }
def compile
code = CodeGenerator.new
# Add debug header if enabled
if debug?
code.line "# Compiled from Liquid template: #{@template.name || '(unnamed)'}"
code.line "# Debug mode enabled - comments contain source locations"
code.line "# Format: # LIQUID | L<line> | <description> | <source>"
code.blank_line
end
# First pass: compile the document body to discover partials and external tags
main_code = CodeGenerator.new
compile_node(@template.root, main_code)
# Lambda signature: (assigns, context, external_handler)
# - assigns: Hash of template variables
# - context: CompiledContext for Drop support
# - external_handler: Proc that handles [:tag, ...] and [:filter, ...] calls
code.line "->(assigns, __context__, __external__) do"
code.indent do
# Initialize the output buffer
code.line '__output__ = +""'
code.blank_line
# Compile partial methods (before main body so they're available)
compile_partials(code)
# Add the main body code
code.raw(main_code.to_s)
code.blank_line
code.line "__output__"
end
code.line "end"
code.to_s
end
# Compile all registered partials as inner methods
def compile_partials(code)
@partials.each do |name, method_name|
source = @partial_sources[name]
next unless source
code.line "# Partial: #{name}"
code.line "#{method_name} = ->(partial_assigns) do"
code.indent do
code.line "__partial_output__ = +''"
code.line "# Merge partial assigns with parent assigns"
code.line "__assigns_backup__ = assigns.dup"
code.line "assigns.merge!(partial_assigns)"
code.blank_line
# Parse and compile the partial
# Note: We need to handle this carefully to avoid circular references
begin
compile_partial_source(source, code)
rescue => e
code.line "# Error compiling partial: #{e.message.inspect}"
code.line "__partial_output__ << '[PARTIAL ERROR: ' + #{name.inspect} + ']'"
end
code.blank_line
code.line "# Restore assigns"
code.line "assigns.replace(__assigns_backup__)"
code.line "__partial_output__"
end
code.line "end"
code.blank_line
end
end
# Compile a partial source string
def compile_partial_source(source, code)
# Parse the partial source using the same environment
environment = @template.instance_variable_get(:@environment) || Liquid::Environment.default
parse_context = Liquid::ParseContext.new(environment: environment)
tokenizer = parse_context.new_tokenizer(source)
document = Liquid::Document.parse(tokenizer, parse_context)
# Compile the partial's body, but swap output variable
code.line "__saved_output__ = __output__"
code.line "__output__ = __partial_output__"
# Compile the document
BlockBodyCompiler.compile(document.body, self, code)
code.line "__output__ = __saved_output__"
end
# Generate a unique variable name for internal use
def generate_var_name(prefix = "v")
@var_counter += 1
"__#{prefix}#{@var_counter}__"
end
# Compile a single node
def compile_node(node, code)
case node
when Document
BlockBodyCompiler.compile(node.body, self, code)
when BlockBody
BlockBodyCompiler.compile(node, self, code)
when String
compile_string(node, code)
when Variable
emit_debug_comment(code, node, "variable")
VariableCompiler.compile(node, self, code)
when Tag
emit_debug_comment(code, node, node.class.name.split('::').last.downcase)
compile_tag(node, code)
else
raise CompileError, "Unknown node type: #{node.class}"
end
end
private
def compile_string(str, code)
return if str.empty?
if debug? && str.length < 40
code.line "# LIQUID | text | #{str.inspect}"
end
code.line "__output__ << #{str.inspect}"
end
def compile_tag(tag, code)
# First, check if the tag implements to_ruby (custom compilation)
if tag.respond_to?(:to_ruby)
tag.to_ruby(code, self)
# Then check for a built-in compiler class
elsif (compiler_class = find_tag_compiler(tag))
compiler_class.compile(tag, self, code)
else
# Unknown tag - yield to caller at runtime
compile_external_tag(tag, code)
end
end
def compile_external_tag(tag, code)
tag_var = register_external_tag(tag)
tag_name = tag.class.name.split('::').last
if debug?
code.line "# External tag: #{tag_name} (yields to caller)"
end
# Yield [:tag, tag_var, assigns] to the external handler
code.line "__output__ << __external__.call(:tag, #{tag_var.inspect}, assigns)"
end
def find_tag_compiler(tag)
case tag
when Liquid::Unless # Check Unless before If since Unless < If
Tags::UnlessCompiler
when Liquid::If
Tags::IfCompiler
when Liquid::Case
Tags::CaseCompiler
when Liquid::For
Tags::ForCompiler
when Liquid::Assign
Tags::AssignCompiler
when Liquid::Capture
Tags::CaptureCompiler
when Liquid::Cycle
Tags::CycleCompiler
when Liquid::Increment
Tags::IncrementCompiler
when Liquid::Decrement
Tags::DecrementCompiler
when Liquid::Raw
Tags::RawCompiler
when Liquid::Echo
Tags::EchoCompiler
when Liquid::Break
Tags::BreakCompiler
when Liquid::Continue
Tags::ContinueCompiler
when Liquid::Comment, Liquid::InlineComment, Liquid::Doc
Tags::CommentCompiler
when Liquid::TableRow
Tags::TableRowCompiler
when Liquid::Render
Tags::RenderCompiler
when Liquid::Include
Tags::IncludeCompiler
when Liquid::Ifchanged
Tags::IfchangedCompiler
else
nil
end
end
# NOTE: compile_helper_methods was removed - helpers are now provided by the
# pre-loaded LR module (compile/runtime.rb). Templates call LR.to_s(), LR.lookup(), etc.
end
# Custom error for compilation issues
class CompileError < StandardError; end
end
end