liquid2 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,16 +21,19 @@ require_relative "nodes/tags/cycle"
21
21
  require_relative "nodes/tags/decrement"
22
22
  require_relative "nodes/tags/doc"
23
23
  require_relative "nodes/tags/echo"
24
+ require_relative "nodes/tags/extends"
24
25
  require_relative "nodes/tags/for"
25
26
  require_relative "nodes/tags/if"
26
27
  require_relative "nodes/tags/include"
27
28
  require_relative "nodes/tags/increment"
28
29
  require_relative "nodes/tags/inline_comment"
29
30
  require_relative "nodes/tags/liquid"
31
+ require_relative "nodes/tags/macro"
30
32
  require_relative "nodes/tags/raw"
31
33
  require_relative "nodes/tags/render"
32
34
  require_relative "nodes/tags/tablerow"
33
35
  require_relative "nodes/tags/unless"
36
+ require_relative "nodes/tags/with"
34
37
 
35
38
  module Liquid2
36
39
  # Template parsing and rendering configuration.
@@ -42,9 +45,30 @@ module Liquid2
42
45
  class Environment
43
46
  attr_reader :tags, :local_namespace_limit, :context_depth_limit, :loop_iteration_limit,
44
47
  :output_stream_limit, :filters, :suppress_blank_control_flow_blocks,
45
- :shorthand_indexes
48
+ :shorthand_indexes, :falsy_undefined, :arithmetic_operators
46
49
 
50
+ # @param context_depth_limit [Integer] The maximum number of times a render context can
51
+ # be extended or copied before a `Liquid2::LiquidResourceLimitError`` is raised.
52
+ # @param globals [Hash[String, untyped]?] Variables that are available to all templates
53
+ # rendered from this environment.
54
+ # @param loader [Liquid2::Loader] An instance of `Liquid2::Loader`. A template loader
55
+ # is responsible for finding and reading templates for `{% include %}` and
56
+ # `{% render %}` tags, or when calling `Liquid2::Environment.get_template(name)`.
57
+ # @param local_namespace_limit [Integer?] The maximum allowed "size" of the template
58
+ # local namespace (variables from `assign` and `capture` tags) before a
59
+ # `Liquid2::LiquidResourceLimitError`` is raised.
60
+ # @param loop_iteration_limit [Integer?] The maximum number of loop iterations allowed
61
+ # before a `LiquidResourceLimitError` is raised.
62
+ # @param output_stream_limit [Integer?] The maximum number of bytes that can be written
63
+ # to a template's output buffer before a `LiquidResourceLimitError` is raised.
64
+ # @param shorthand_indexes [bool] When `true`, allow shorthand dotted array indexes as
65
+ # well as bracketed indexes in variable paths. Defaults to `false`.
66
+ # @param suppress_blank_control_flow_blocks [bool] When `true`, suppress blank control
67
+ # flow block output, so as not to include unnecessary whitespace. Defaults to `true`.
68
+ # @param undefined [singleton(Liquid2::Undefined)] A singleton returning an instance of
69
+ # `Liquid2::Undefined`, which is used to represent template variables that don't exist.
47
70
  def initialize(
71
+ arithmetic_operators: false,
48
72
  context_depth_limit: 30,
49
73
  globals: nil,
50
74
  loader: nil,
@@ -53,7 +77,8 @@ module Liquid2
53
77
  output_stream_limit: nil,
54
78
  shorthand_indexes: false,
55
79
  suppress_blank_control_flow_blocks: true,
56
- undefined: Undefined
80
+ undefined: Undefined,
81
+ falsy_undefined: true
57
82
  )
58
83
  # A mapping of tag names to objects responding to `parse(token, parser)`.
59
84
  @tags = {}
@@ -63,6 +88,10 @@ module Liquid2
63
88
  # keyword argument.
64
89
  @filters = {}
65
90
 
91
+ # When `true`, arithmetic operators `+`, `-`, `*`, `/`, `%` and `**` are enabled.
92
+ # Defaults to `false`.
93
+ @arithmetic_operators = arithmetic_operators
94
+
66
95
  # The maximum number of times a render context can be extended or copied before
67
96
  # a Liquid2::LiquidResourceLimitError is raised.
68
97
  @context_depth_limit = context_depth_limit
@@ -99,10 +128,14 @@ module Liquid2
99
128
  # unnecessary whitespace. Defaults to `true`.
100
129
  @suppress_blank_control_flow_blocks = suppress_blank_control_flow_blocks
101
130
 
102
- # An instance of `Liquid2::Undefined` used to represent template variables that
103
- # don't exist.
131
+ # A singleton returning an instance of `Liquid2::Undefined`, which is used to
132
+ # represent template variables that don't exist.
104
133
  @undefined = undefined
105
134
 
135
+ # When `true` (the default), undefined variables are considered falsy and do not
136
+ # raise an error when tested for truthiness.
137
+ @falsy_undefined = falsy_undefined
138
+
106
139
  # Override `setup_tags_and_filters` in environment subclasses to configure custom
107
140
  # tags and/or filters.
108
141
  setup_tags_and_filters
@@ -118,8 +151,8 @@ module Liquid2
118
151
  name: name, path: path, up_to_date: up_to_date,
119
152
  globals: make_globals(globals), overlay: overlay)
120
153
  rescue LiquidError => e
121
- e.source = source
122
- e.template_name = name unless name.empty?
154
+ e.source = source unless e.source
155
+ e.template_name = name unless e.template_name || name.empty?
123
156
  raise
124
157
  end
125
158
 
@@ -155,6 +188,20 @@ module Liquid2
155
188
  @filters.delete(name)
156
189
  end
157
190
 
191
+ # Add or replace a tag.
192
+ # @param name [String] The tag's name, as used by template authors.
193
+ # @param tag [responds to parse: ([Symbol, String?, Integer], Parser) -> Tag]
194
+ def register_tag(name, tag)
195
+ @tags[name] = tag
196
+ end
197
+
198
+ # Remove a tag from the tag register.
199
+ # @param name [String] The name of the tag.
200
+ # @return [_Tag | nil]
201
+ def delete_tag(name)
202
+ @tags.delete(name)
203
+ end
204
+
158
205
  def setup_tags_and_filters
159
206
  @tags["#"] = InlineComment
160
207
  @tags["assign"] = AssignTag
@@ -167,15 +214,20 @@ module Liquid2
167
214
  @tags["decrement"] = DecrementTag
168
215
  @tags["doc"] = DocTag
169
216
  @tags["echo"] = EchoTag
217
+ @tags["extends"] = ExtendsTag
218
+ @tags["block"] = BlockTag
170
219
  @tags["for"] = ForTag
171
220
  @tags["if"] = IfTag
172
221
  @tags["include"] = IncludeTag
173
222
  @tags["increment"] = IncrementTag
174
223
  @tags["liquid"] = LiquidTag
224
+ @tags["macro"] = MacroTag
225
+ @tags["call"] = CallTag
175
226
  @tags["raw"] = RawTag
176
227
  @tags["render"] = RenderTag
177
228
  @tags["tablerow"] = TableRowTag
178
229
  @tags["unless"] = UnlessTag
230
+ @tags["with"] = WithTag
179
231
 
180
232
  register_filter("abs", Liquid2::Filters.method(:abs))
181
233
  register_filter("append", Liquid2::Filters.method(:append))
@@ -7,10 +7,10 @@ module Liquid2
7
7
 
8
8
  FULL_MESSAGE = ((RUBY_VERSION.split(".")&.map(&:to_i) <=> [3, 2, 0]) || -1) < 1
9
9
 
10
- def initialize(message, token = nil)
10
+ def initialize(message, token = nil, template_name: nil)
11
11
  super(message)
12
12
  @token = token
13
- @template_name = nil
13
+ @template_name = template_name
14
14
  @source = nil
15
15
  end
16
16
 
@@ -76,4 +76,6 @@ module Liquid2
76
76
  class LiquidResourceLimitError < LiquidError; end
77
77
  class UndefinedError < LiquidError; end
78
78
  class DisabledTagError < LiquidError; end
79
+ class TemplateInheritanceError < LiquidError; end
80
+ class RequiredBlockError < TemplateInheritanceError; end
79
81
  end
@@ -22,4 +22,24 @@ module Liquid2
22
22
 
23
23
  def children = [@value]
24
24
  end
25
+
26
+ # A macro parameter with a name and optional default value.
27
+ class Parameter < Expression
28
+ attr_reader :value, :name, :sym
29
+
30
+ # @param name [String]
31
+ # @param value [Expression?]
32
+ def initialize(token, name, value)
33
+ super(token)
34
+ @name = name
35
+ @sym = name.to_sym
36
+ @value = value
37
+ end
38
+
39
+ def evaluate(context)
40
+ [@name, context.evaluate(@value)]
41
+ end
42
+
43
+ def children = [@value]
44
+ end
25
45
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "blank"
4
+ require_relative "../expression"
5
+
6
+ module Liquid2 # :nodoc:
7
+ # Base class for all arithmetic expressions.
8
+ class ArithmeticExpression < Expression
9
+ # @param left [Expression]
10
+ # @param right [Expression]
11
+ def initialize(token, left, right)
12
+ super(token)
13
+ @left = left
14
+ @right = right
15
+ end
16
+
17
+ def children = [@left, @right]
18
+
19
+ protected
20
+
21
+ def inner_evaluate(context)
22
+ left = context.evaluate(@left)
23
+ right = context.evaluate(@right)
24
+ left = left.to_liquid(context) if left.respond_to?(:to_liquid)
25
+ right = right.to_liquid(context) if right.respond_to?(:to_liquid)
26
+ [Liquid2::Filters.to_decimal(left), Liquid2::Filters.to_decimal(right)]
27
+ end
28
+ end
29
+
30
+ # Infix addition
31
+ class Plus < ArithmeticExpression
32
+ def evaluate(context)
33
+ left, right = inner_evaluate(context)
34
+ left + right
35
+ end
36
+ end
37
+
38
+ # Infix subtraction
39
+ class Minus < ArithmeticExpression
40
+ def evaluate(context)
41
+ left, right = inner_evaluate(context)
42
+ left - right
43
+ end
44
+ end
45
+
46
+ # Infix multiplication
47
+ class Times < ArithmeticExpression
48
+ def evaluate(context)
49
+ left, right = inner_evaluate(context)
50
+ left * right
51
+ end
52
+ end
53
+
54
+ # Infix division
55
+ class Divide < ArithmeticExpression
56
+ def evaluate(context)
57
+ left, right = inner_evaluate(context)
58
+ left / right
59
+ rescue ZeroDivisionError => e
60
+ raise LiquidTypeError.new(e.message, nil)
61
+ end
62
+ end
63
+
64
+ # Infix modulo
65
+ class Modulo < ArithmeticExpression
66
+ def evaluate(context)
67
+ left, right = inner_evaluate(context)
68
+ left % right
69
+ rescue ZeroDivisionError => e
70
+ raise LiquidTypeError.new(e.message, nil)
71
+ end
72
+ end
73
+
74
+ # Infix exponentiation
75
+ class Pow < ArithmeticExpression
76
+ def evaluate(context)
77
+ left, right = inner_evaluate(context)
78
+ left**right
79
+ end
80
+ end
81
+
82
+ # Prefix negation
83
+ class Negative < Expression
84
+ # @param right [Expression]
85
+ def initialize(token, right)
86
+ super(token)
87
+ @right = right
88
+ end
89
+
90
+ def evaluate(context)
91
+ right = context.evaluate(@right)
92
+ value = Liquid2::Filters.to_decimal(right,
93
+ default: nil) || context.env.undefined(
94
+ "-(#{Liquid2.to_output_string(right)})",
95
+ node: self
96
+ )
97
+ value.send(:-@)
98
+ end
99
+
100
+ def children = [@right]
101
+ end
102
+
103
+ # Prefix positive
104
+ class Positive < Expression
105
+ # @param right [Expression]
106
+ def initialize(token, right)
107
+ super(token)
108
+ @right = right
109
+ end
110
+
111
+ def evaluate(context)
112
+ right = context.evaluate(@right)
113
+ value = Liquid2::Filters.to_decimal(right,
114
+ default: nil) || context.env.undefined(
115
+ "+(#{Liquid2.to_output_string(right)})",
116
+ node: self
117
+ )
118
+ value.send(:+@)
119
+ end
120
+
121
+ def children = [@right]
122
+ end
123
+ end
@@ -12,7 +12,8 @@ module Liquid2
12
12
  end
13
13
 
14
14
  def evaluate(context)
15
- Liquid2.truthy?(context, context.evaluate(@expr))
15
+ value = context.evaluate(@expr)
16
+ Liquid2.truthy?(context, value.respond_to?(:to_liquid) ? value.to_liquid(context) : value)
16
17
  end
17
18
 
18
19
  def children = [@expr]
@@ -15,8 +15,10 @@ module Liquid2
15
15
 
16
16
  def evaluate(context)
17
17
  left = context.evaluate(@left)
18
+ return left if @filters.nil?
19
+
18
20
  index = 0
19
- while (filter = @filters[index])
21
+ while (filter = (@filters || raise)[index])
20
22
  left = filter.evaluate(left, context)
21
23
  index += 1
22
24
  end
@@ -81,7 +83,7 @@ module Liquid2
81
83
  attr_reader :name, :args
82
84
 
83
85
  # @param name [String]
84
- # @param args [Array[Expression]]
86
+ # @param args [Array[Expression]?]
85
87
  def initialize(token, name, args)
86
88
  super(token)
87
89
  @name = name
@@ -92,36 +94,18 @@ module Liquid2
92
94
  filter, with_context = context.env.filters[@name]
93
95
  raise LiquidFilterNotFoundError.new("unknown filter #{@name.inspect}", @token) unless filter
94
96
 
95
- positional_args, keyword_args = evaluate_args(context)
96
- keyword_args[:context] = context if with_context
97
-
98
- if keyword_args.empty?
99
- filter.call(left, *positional_args) # steep:ignore
100
- else
101
- filter.call(left, *positional_args, **keyword_args) # steep:ignore
102
- end
103
- rescue ArgumentError, TypeError => e
104
- raise LiquidArgumentError.new(e.message, @token)
105
- end
106
-
107
- def children = @args
97
+ return filter.call(left) if @args.nil? && !with_context # steep:ignore
98
+ return filter.call(left, context: context) if @args.nil? && with_context # steep:ignore
108
99
 
109
- private
110
-
111
- # @param context [RenderContext]
112
- # @return [positional arguments, keyword arguments] An array with two elements.
113
- # The first is an array of evaluates positional arguments. The second is a hash
114
- # of keyword names to evaluated keyword values.
115
- def evaluate_args(context)
116
100
  positional_args = [] # @type var positional_args: Array[untyped]
117
101
  keyword_args = {} # @type var keyword_args: Hash[Symbol, untyped]
118
102
 
119
103
  index = 0
120
104
  loop do
121
105
  # `@args[index]` could be `false` or `nil`
122
- break if index >= @args.length
106
+ break if index >= @args.length # steep:ignore
123
107
 
124
- arg = @args[index]
108
+ arg = @args[index] # steep:ignore
125
109
  index += 1
126
110
  if arg.respond_to?(:sym)
127
111
  keyword_args[arg.sym] = context.evaluate(arg.value)
@@ -130,7 +114,17 @@ module Liquid2
130
114
  end
131
115
  end
132
116
 
133
- [positional_args, keyword_args]
117
+ keyword_args[:context] = context if with_context
118
+
119
+ if keyword_args.empty?
120
+ filter.call(left, *positional_args) # steep:ignore
121
+ else
122
+ filter.call(left, *positional_args, **keyword_args) # steep:ignore
123
+ end
124
+ rescue ArgumentError, TypeError => e
125
+ raise LiquidArgumentError.new(e.message, @token)
134
126
  end
127
+
128
+ def children = @args || []
135
129
  end
136
130
  end
@@ -19,6 +19,8 @@ module Liquid2
19
19
 
20
20
  def children = [@expr]
21
21
 
22
+ def scope = @params
23
+
22
24
  # Apply this lambda function to elements from _enum_.
23
25
  # @param context [RenderContext]
24
26
  # @param enum [Enumerable<Object>]
@@ -33,11 +33,13 @@ module Liquid2
33
33
  elsif obj.is_a?(String)
34
34
  # TODO: optionally enable/disable string iteration
35
35
  obj.each_char.to_a
36
- elsif obj.respond_to?(:each)
37
- # TODO: special lazy drop slicing
38
- # #each and #slice is our enumerable drop interface
39
- # TODO: or just #to_a
40
- obj.each.to_a
36
+ elsif obj.respond_to?(:slice)
37
+ # Special lazy drop slicing
38
+ return obj.slice(context.evaluate(@offset),
39
+ context.evaluate(@limit),
40
+ @reversed) || EMPTY_ENUM
41
+ elsif obj.is_a?(Enumerable) # rubocop:disable Lint/DuplicateBranch
42
+ obj.to_a
41
43
  else
42
44
  EMPTY_ENUM
43
45
  end
@@ -8,6 +8,8 @@ module Liquid2
8
8
  class Path < Expression
9
9
  attr_reader :segments, :head
10
10
 
11
+ RE_PROPERTY = /\A[\u0080-\uFFFFa-zA-Z_][\u0080-\uFFFFa-zA-Z0-9_-]*\Z/
12
+
11
13
  # @param segments [Array[String | Integer | Path]]
12
14
  def initialize(token, segments)
13
15
  super(token)
@@ -19,8 +21,9 @@ module Liquid2
19
21
  context.fetch(@head, @segments, node: self)
20
22
  end
21
23
 
22
- # TODO: fix and optimize (store it on the instance)
23
- def to_s = "#{@head}.#{@segments.map(&:to_s).join(".")}"
24
+ def to_s
25
+ segment_to_s(@head, head: true) + @segments.map { |segment| segment_to_s(segment) }.join
26
+ end
24
27
 
25
28
  def children
26
29
  if @head.is_a?(Path)
@@ -29,5 +32,19 @@ module Liquid2
29
32
  @segments.filter { |segment| segment.is_a?(Path) }
30
33
  end
31
34
  end
35
+
36
+ private
37
+
38
+ def segment_to_s(segment, head: false)
39
+ if segment.is_a?(String)
40
+ if segment.match?(RE_PROPERTY)
41
+ "#{head ? "" : "."}#{segment}"
42
+ else
43
+ "[#{segment.inspect}]"
44
+ end
45
+ else
46
+ "[#{segment}]"
47
+ end
48
+ end
32
49
  end
33
50
  end
@@ -4,7 +4,7 @@ require_relative "blank"
4
4
  require_relative "../expression"
5
5
 
6
6
  module Liquid2 # :nodoc:
7
- # Base for comparison expressions.
7
+ # Base class for all comparison expressions.
8
8
  class ComparisonExpression < Expression
9
9
  # @param left [Expression]
10
10
  # @param right [Expression]
@@ -63,8 +63,7 @@ module Liquid2
63
63
  end
64
64
 
65
65
  # Cast _obj_ to a date and time. Return `nil` if casting fails.
66
- #
67
- # TODO: This was copied from Shopify/liquid. Include their license and copyright.
66
+ # NOTE: This was copied from Shopify/liquid.
68
67
  def self.to_date(obj)
69
68
  return obj if obj.respond_to?(:strftime)
70
69
 
@@ -29,7 +29,6 @@ module Liquid2
29
29
  when :undefined
30
30
  left.compact
31
31
  else
32
- # TODO: stringify key?
33
32
  left.reject do |item|
34
33
  item.respond_to?(:fetch) ? item.fetch(key, nil).nil? : true
35
34
  end
@@ -3,6 +3,8 @@
3
3
  module Liquid2
4
4
  # Liquid filters and helper methods.
5
5
  module Filters
6
+ INFINITY_ARRAY = [Float::INFINITY].freeze # : [Float]
7
+
6
8
  def self.sort(left, key = nil, context:)
7
9
  left = Liquid2::Filters.to_enumerable(left)
8
10
 
@@ -82,12 +84,11 @@ module Liquid2
82
84
  end
83
85
 
84
86
  def self.ints(obj)
85
- case obj
86
- when Integer, Float, BigDecimal
87
+ if obj.is_a?(Integer) || obj.is_a?(Float) || obj.is_a?(BigDecimal)
87
88
  [obj]
88
89
  else
89
- numeric = obj.to_s.scan(/-?\d+/)
90
- return [Float::INFINITY] if numeric.empty?
90
+ numeric = obj.to_s.scan(/(?<=\.)0+|-?\d+/)
91
+ return INFINITY_ARRAY if numeric.empty?
91
92
 
92
93
  numeric.map(&:to_i)
93
94
  end
@@ -31,6 +31,7 @@ module Liquid2
31
31
  def load(env, name, globals: nil, context: nil, **kwargs)
32
32
  data = get_source(env, name, context: context, **kwargs)
33
33
  path = Pathname.new(data.name)
34
+ # FIXME: name and path
34
35
  env.parse(data.source,
35
36
  name: path.basename.to_s,
36
37
  path: data.name,
@@ -5,6 +5,8 @@ require_relative "../../tag"
5
5
  module Liquid2
6
6
  # The standard _doc_ tag.
7
7
  class DocTag < Tag
8
+ attr_reader :text
9
+
8
10
  def self.parse(token, parser)
9
11
  parser.carry_whitespace_control
10
12
  parser.eat(:token_tag_end)