160 lines
5.5 KiB
Ruby
160 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
|