whoward/cadenza

View on GitHub
lib/cadenza/source_renderer.rb

Summary

Maintainability
A
3 hrs
Test Coverage

# frozen_string_literal: true

module Cadenza
  # SourceRenderer is a rendering implementation that turns a Cadenza AST back
  # into Cadenza source code.
  #
  # This is mainly intended for users who wish to migrate templates stored in
  # databases, or other storage devices, as their product progresses.
  #
  # For example: if in v1.0 of your app you define a filter X and deprecate it
  # in favour of filter Y you can use this renderer to automatically replace
  # instances of filter X with filter Y
  #
  # I'm sure there are many other exciting use cases for the imaginitive user,
  # feel free to let me know what else you do with it!
  #
  class SourceRenderer < BaseRenderer
    # This exception is raised when you try to transition to an undefined state
    IllegalStateError = Class.new(RuntimeError)

    # This exception is raised when you try to transition from one state to
    # another which is not allowed
    IllegalStateTransitionError = Class.new(RuntimeError)

    # A list of all valid states for the renderer
    VALID_STATES = %i[text var tag].freeze

    # returns the current state of the renderer (see {#ValidStates})
    attr_reader :state

    # Renders the document given with the given context directly to a string
    # returns.
    # @param [DocumentNode] document_node the root of the AST you want to render.
    # @param [Context] context the context object to render the document with
    def self.render(document_node, context = {})
      io = StringIO.new
      new(io).render(document_node, context)
      io.string
    end

    # creates a new {SourceRenderer} and places it into the :text state
    def initialize(*args)
      @state = :text
      super
    end

    # transitions from the current state into the new state and emits opening
    # and closing tag markers appropriately during the transition.
    #
    # @raise [IllegalStateError] if you try to transition to an invalid state
    # @raise [IllegalStateTransitionError] if you try to transition from one
    #        one state to another which is not allowed
    def state=(new_state)
      # if trying to transition to a new state raise an exception
      raise IllegalStateError, new_state unless VALID_STATES.include?(new_state)

      # no special transition for the same state
      return if @state == new_state

      # handle any actions that occur on that state transition
      case @state
      when :text
        output << '{{ ' if new_state == :var
        output << '{% ' if new_state == :tag
      when :var
        output << ' }}' if new_state == :text
        raise IllegalStateTransitionError if new_state == :tag
      when :tag
        output << ' %}' if new_state == :text
        raise IllegalStateTransitionError if new_state == :var
      end

      # update to the new state
      @state = new_state
    end

    private

    def render_document(node, context, blocks)
      output << %({% extends "#{node.extends}" %}) if node.extends

      node.children.each { |child| render(child, context, blocks) }

      self.state = :text
    end

    def render_text(node, _context, _blocks)
      self.state = :text
      output << node.text
    end

    def render_constant(node, _context, _blocks)
      self.state = :var unless state == :tag
      output <<
        if node.value.is_a?(String)
          node.value.inspect
        else
          node.value
        end
    end

    def render_variable(node, context, blocks)
      self.state = :var unless state == :tag

      output << node.identifier

      node.parameters.each_with_index do |param_node, i|
        output << ' '
        render(param_node, context, blocks)
        output << ',' if i < node.parameters.length - 1
      end
    end

    def render_filtered_value(node, context, blocks)
      state == :var unless state == :tag

      render(node.value, context, blocks)

      node.filters.each do |filter|
        output << " | #{filter.identifier}"

        output << ': ' if filter.parameters.any?

        filter.parameters.each_with_index do |param, i|
          render(param, context, blocks)

          output << ', ' if i < filter.parameters.length - 1
        end
      end
    end

    def render_operation(node, context, blocks)
      self.state = :var unless state == :tag

      # calculate the operator precedence of the left, right and parent node
      node_precedence  = calculate_precedence(node)
      left_precedence  = calculate_precedence(node.left)
      right_precedence = calculate_precedence(node.right)

      need_left_brackets = left_precedence < node_precedence
      need_right_brackets = right_precedence <= node_precedence &&
                            !(node.right.is_a?(OperationNode) && node.right.operator == node.operator)

      # render the left node, wrapping in brackets if it is lower precedence
      output << '(' if need_left_brackets
      render(node.left, context, blocks)
      output << ')' if need_left_brackets

      # render the parent node's operator
      output << " #{node.operator} "

      # render the right node, wrapping it brackets if it is lower precedences
      output << '(' if need_right_brackets
      render(node.right, context, blocks)
      output << ')' if need_right_brackets
    end

    def render_if(node, context, blocks)
      self.state = :tag

      output << 'if '

      render(node.expression, context, blocks)

      self.state = :text

      node.true_children.each { |n| render(n, context, blocks) }

      output << '{% endif %}'
    end

    def render_for(node, context, blocks)
      self.state = :tag

      output << "for #{node.iterator.identifier} in "

      render(node.iterable, context, blocks)

      self.state = :text

      node.children.each { |n| render(n, context, blocks) }

      output << '{% endfor %}'
    end

    def render_block(node, context, blocks)
      self.state = :tag

      output << "block #{node.name}"

      self.state = :text

      node.children.each { |n| render(n, context, blocks) }

      output << '{% endblock %}'
    end

    def render_generic_block(node, context, blocks)
      self.state = :tag

      output << node.identifier

      output << ' ' if node.parameters.any?

      node.parameters.each_with_index do |param, i|
        render(param, context, blocks)

        output << ', ' if i < node.parameters.length - 1
      end

      self.state = :text

      node.children.each { |n| render(n, context, blocks) }

      output << '{% end %}'
    end

    def calculate_precedence(node)
      return 4 unless node.is_a?(OperationNode)

      case node.operator
      when '+', '-' then 2
      when '*', '/' then 3
      else 1
      end
    end
  end
end