161 lines
		
	
	
		
			5.5 KiB
		
	
	
	
		
			Ruby
		
	
	
	
			
		
		
	
	
			161 lines
		
	
	
		
			5.5 KiB
		
	
	
	
		
			Ruby
		
	
	
	
# rubocop:disable Naming/FileName
 | 
						|
# frozen_string_literal: true
 | 
						|
 | 
						|
module Gitlab
 | 
						|
  module TemplateParser
 | 
						|
    # AST nodes to evaluate when rendering a template.
 | 
						|
    #
 | 
						|
    # Evaluating an AST is done by walking over the nodes and calling
 | 
						|
    # `evaluate`. This method takes two arguments:
 | 
						|
    #
 | 
						|
    # 1. An instance of `EvalState`, used for tracking data such as the number
 | 
						|
    #    of nested loops.
 | 
						|
    # 2. An object used as the data for the current scope. This can be an Array,
 | 
						|
    #    Hash, String, or something else. It's up to the AST node to determine
 | 
						|
    #    what to do with it.
 | 
						|
    #
 | 
						|
    # While tree walking interpreters (such as implemented here) aren't usually
 | 
						|
    # the fastest type of interpreter, they are:
 | 
						|
    #
 | 
						|
    # 1. Fast enough for our use case
 | 
						|
    # 2. Easy to implement and maintain
 | 
						|
    #
 | 
						|
    # In addition, our AST interpreter doesn't allow for arbitrary code
 | 
						|
    # execution, unlike existing template engines such as Mustache
 | 
						|
    # (https://github.com/mustache/mustache/issues/244) or ERB.
 | 
						|
    #
 | 
						|
    # Our interpreter also takes care of limiting the number of nested loops.
 | 
						|
    # And unlike Liquid, our interpreter is much smaller and thus has a smaller
 | 
						|
    # attack surface. Liquid isn't without its share of issues, such as
 | 
						|
    # https://github.com/Shopify/liquid/pull/1071.
 | 
						|
    #
 | 
						|
    # We also evaluated using Handlebars using the project
 | 
						|
    # https://github.com/SmartBear/ruby-handlebars. Sadly, this implementation
 | 
						|
    # of Handlebars doesn't support control of whitespace
 | 
						|
    # (https://github.com/SmartBear/ruby-handlebars/issues/37), and the project
 | 
						|
    # didn't appear to be maintained that much.
 | 
						|
    #
 | 
						|
    # This doesn't mean these template engines aren't good, instead it means
 | 
						|
    # they won't work for our use case. For more information, refer to the
 | 
						|
    # comment https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50063#note_469293322.
 | 
						|
    module AST
 | 
						|
      # An identifier in a selector.
 | 
						|
      Identifier = Struct.new(:name) do
 | 
						|
        def evaluate(state, data)
 | 
						|
          return data if name == 'it'
 | 
						|
 | 
						|
          data[name] if data.is_a?(Hash)
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # An integer used in a selector.
 | 
						|
      Integer = Struct.new(:value) do
 | 
						|
        def evaluate(state, data)
 | 
						|
          data[value] if data.is_a?(Array)
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # A selector used for loading a value.
 | 
						|
      Selector = Struct.new(:steps) do
 | 
						|
        def evaluate(state, data)
 | 
						|
          steps.reduce(data) do |current, step|
 | 
						|
            break if current.nil?
 | 
						|
 | 
						|
            step.evaluate(state, current)
 | 
						|
          end
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # A tag used for displaying a value in the output.
 | 
						|
      Variable = Struct.new(:selector) do
 | 
						|
        def evaluate(state, data)
 | 
						|
          selector.evaluate(state, data).to_s
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # A collection of zero or more expressions.
 | 
						|
      Expressions = Struct.new(:nodes) do
 | 
						|
        def evaluate(state, data)
 | 
						|
          nodes.map { |node| node.evaluate(state, data) }.join('')
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # A single text node.
 | 
						|
      Text = Struct.new(:text) do
 | 
						|
        def evaluate(*)
 | 
						|
          text
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # An `if` expression, with an optional `else` clause.
 | 
						|
      If = Struct.new(:condition, :true_body, :false_body) do
 | 
						|
        def evaluate(state, data)
 | 
						|
          result =
 | 
						|
            if truthy?(condition.evaluate(state, data))
 | 
						|
              true_body.evaluate(state, data)
 | 
						|
            elsif false_body
 | 
						|
              false_body.evaluate(state, data)
 | 
						|
            end
 | 
						|
 | 
						|
          result.to_s
 | 
						|
        end
 | 
						|
 | 
						|
        def truthy?(value)
 | 
						|
          # We treat empty collections and such as false, removing the need for
 | 
						|
          # some sort of `if length(x) > 0` expression.
 | 
						|
          value.respond_to?(:empty?) ? !value.empty? : !!value
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # An `each` expression.
 | 
						|
      Each = Struct.new(:collection, :body) do
 | 
						|
        def evaluate(state, data)
 | 
						|
          values = collection.evaluate(state, data)
 | 
						|
 | 
						|
          return '' unless values.respond_to?(:each)
 | 
						|
 | 
						|
          # While unlikely to happen, it's possible users attempt to nest many
 | 
						|
          # loops in order to negatively impact the GitLab instance. To make
 | 
						|
          # this more difficult, we limit the number of nested loops a user can
 | 
						|
          # create.
 | 
						|
          state.enter_loop do
 | 
						|
            values.map { |value| body.evaluate(state, value) }.join('')
 | 
						|
          end
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      # A class for transforming a raw Parslet AST into a more structured/easier
 | 
						|
      # to work with AST.
 | 
						|
      #
 | 
						|
      # For more information about Parslet transformations, refer to the
 | 
						|
      # documentation at http://kschiess.github.io/parslet/transform.html.
 | 
						|
      class Transformer < Parslet::Transform
 | 
						|
        rule(ident: simple(:name)) { Identifier.new(name.to_s) }
 | 
						|
        rule(int: simple(:name)) { Integer.new(name.to_i) }
 | 
						|
        rule(text: simple(:text)) { Text.new(text.to_s) }
 | 
						|
        rule(exprs: subtree(:nodes)) { Expressions.new(nodes) }
 | 
						|
        rule(selector: sequence(:steps)) { Selector.new(steps) }
 | 
						|
        rule(selector: simple(:step)) { Selector.new([step]) }
 | 
						|
        rule(variable: simple(:selector)) { Variable.new(selector) }
 | 
						|
        rule(each: simple(:values), body: simple(:body)) do
 | 
						|
          Each.new(values, body)
 | 
						|
        end
 | 
						|
 | 
						|
        rule(if: simple(:cond), true_body: simple(:true_body)) do
 | 
						|
          If.new(cond, true_body)
 | 
						|
        end
 | 
						|
 | 
						|
        rule(
 | 
						|
          if: simple(:cond),
 | 
						|
          true_body: simple(:true_body),
 | 
						|
          false_body: simple(:false_body)
 | 
						|
        ) do
 | 
						|
          If.new(cond, true_body, false_body)
 | 
						|
        end
 | 
						|
      end
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
# rubocop:enable Naming/FileName
 |