liquid 5.6.0 → 5.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1a39a98605d86cc2cf586a59d1a402ed708ad3722061083514eeeea602249d9
4
- data.tar.gz: d3de02a25cada366198f08959e8d6565b120b961978810b98dda3170acee7b7e
3
+ metadata.gz: 72a0f18697a90c81db846fc86027cbec36198105fb458d40989f88b1acd55687
4
+ data.tar.gz: ba8f6ecc9612f737f954006109b91759ea7cea41074de2ab38e73221d4e18be2
5
5
  SHA512:
6
- metadata.gz: fa084464c4927940f8edc1c0206858bdf4c39ca5bd1b632d2786ab6b11b235f5a5696dad9c9e9bdf79640d897539baaa5c56044230ed6901d1c34f930c0e3f0a
7
- data.tar.gz: dbe05e17ceb7c73461ed0b4f838a03af7853ec47a454ada51c632763adb31e28cf8e4fd3d89c2ed0c5c2f8d91eea2e3b661c8257ab266d7895c398ef1ac047f5
6
+ metadata.gz: ad608314f023c78123d3cf57a3c9b54229ec7dca712f1b3897218c27880e5f78d7188cdce775d15759960803a31c5e7d1adc81c9282ab784a6f6fa50efeb051e
7
+ data.tar.gz: 6988a403789ca9a3a3d3d48e5cba0639f81226b3cd8af477dcce1d8eeb9db3693e90b373ea391f5d458a723ec40d7c0c6d28f7e445d3d0562b219e9cbcfff1ee
data/History.md CHANGED
@@ -1,11 +1,53 @@
1
1
  # Liquid Change Log
2
2
 
3
- ## 5.6.0 (unreleased)
3
+ ## 5.8.0 (unreleased)
4
+
5
+ ## 5.7.0 2025-01-16
6
+
7
+ ### Features
8
+ * Add `find`, `find_index`, `has`, and `reject` filters to arrays
9
+ * Compatibility with Ruby 3.4
10
+
11
+ ## 5.6.4 2025-01-14
4
12
 
5
13
  ### Fixes
14
+ * Add a default `string_scanner` to avoid errors with `Liquid::VariableLookup.parse("foo.bar")` [Ian Ker-Seymer]
6
15
 
7
- * Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar]
16
+ ## 5.6.3 2025-01-13
17
+ * Remove `lru_redux` dependency [Michael Go]
18
+
19
+ ## 5.6.2 2025-01-13
20
+
21
+ ### Fixes
22
+ * Preserve the old behavior of requiring floats to start with a digit [Michael Go]
8
23
 
24
+ ## 5.6.1 2025-01-13
25
+
26
+ ### Performance improvements
27
+ * Faster Expression parser / Tokenizer with StringScanner [Michael Go]
28
+
29
+ ## 5.6.0 2024-12-19
30
+
31
+ ### Architectural changes
32
+ * Added new `Environment` class to manage configuration and state that was previously stored in `Template` [Ian Ker-Seymer]
33
+ * Moved tag registration from `Template` to `Environment` [Ian Ker-Seymer]
34
+ * Removed `StrainerFactory` in favor of `Environment`-based strainer creation [Ian Ker-Seymer]
35
+ * Consolidated standard tags into a new `Tags` module with `STANDARD_TAGS` constant [Ian Ker-Seymer]
36
+
37
+ ### Performance improvements
38
+ * Optimized `Lexer` with a new `Lexer2` implementation using jump tables for faster tokenization, requires Ruby 3.4 [Ian Ker-Seymer]
39
+ * Improved variable rendering with specialized handling for different types [Michael Go]
40
+ * Reduced array allocations by using frozen empty constants [Michael Go]
41
+
42
+ ### API changes
43
+ * Deprecated several `Template` class methods in favor of `Environment` methods [Ian Ker-Seymer]
44
+ * Added deprecation warnings system [Ian Ker-Seymer]
45
+ * Changed how filters and tags are registered to use Environment [Ian Ker-Seymer]
46
+
47
+ ### Fixes
48
+ * Fixed table row handling of break interrupts [Alex Coco]
49
+ * Improved variable output handling for arrays [Ian Ker-Seymer]
50
+ * Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar]
9
51
 
10
52
  ## 5.5.0 2024-03-21
11
53
 
data/README.md CHANGED
@@ -91,7 +91,7 @@ Liquid::Template.parse(<<~LIQUID, environment: email_environment)
91
91
  LIQUID
92
92
  ```
93
93
 
94
- By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage.
94
+ By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
95
95
 
96
96
  ### Error Modes
97
97
 
@@ -40,6 +40,10 @@ module Liquid
40
40
  @global_filter = nil
41
41
  @disabled_tags = {}
42
42
 
43
+ # Instead of constructing new StringScanner objects for each Expression parse,
44
+ # we recycle the same one.
45
+ @string_scanner = StringScanner.new("")
46
+
43
47
  @registers.static[:cached_partials] ||= {}
44
48
  @registers.static[:file_system] ||= environment.file_system
45
49
  @registers.static[:template_factory] ||= Liquid::TemplateFactory.new
@@ -176,7 +180,7 @@ module Liquid
176
180
  # Example:
177
181
  # products == empty #=> products.empty?
178
182
  def [](expression)
179
- evaluate(Expression.parse(expression))
183
+ evaluate(Expression.parse(expression, @string_scanner))
180
184
  end
181
185
 
182
186
  def key?(key)
@@ -10,37 +10,113 @@ module Liquid
10
10
  'true' => true,
11
11
  'false' => false,
12
12
  'blank' => '',
13
- 'empty' => ''
13
+ 'empty' => '',
14
+ # in lax mode, minus sign can be a VariableLookup
15
+ # For simplicity and performace, we treat it like a literal
16
+ '-' => VariableLookup.parse("-", nil).freeze,
14
17
  }.freeze
15
18
 
16
- INTEGERS_REGEX = /\A(-?\d+)\z/
17
- FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
19
+ DOT = ".".ord
20
+ ZERO = "0".ord
21
+ NINE = "9".ord
22
+ DASH = "-".ord
18
23
 
19
24
  # Use an atomic group (?>...) to avoid pathological backtracing from
20
25
  # malicious input as described in https://github.com/Shopify/liquid/issues/1357
21
- RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
26
+ RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
27
+ INTEGER_REGEX = /\A(-?\d+)\z/
28
+ FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
22
29
 
23
- def self.parse(markup)
24
- return nil unless markup
30
+ class << self
31
+ def parse(markup, ss = StringScanner.new(""), cache = nil)
32
+ return unless markup
25
33
 
26
- markup = markup.strip
27
- if (markup.start_with?('"') && markup.end_with?('"')) ||
28
- (markup.start_with?("'") && markup.end_with?("'"))
29
- return markup[1..-2]
34
+ markup = markup.strip # markup can be a frozen string
35
+
36
+ if (markup.start_with?('"') && markup.end_with?('"')) ||
37
+ (markup.start_with?("'") && markup.end_with?("'"))
38
+ return markup[1..-2]
39
+ elsif LITERALS.key?(markup)
40
+ return LITERALS[markup]
41
+ end
42
+
43
+ # Cache only exists during parsing
44
+ if cache
45
+ return cache[markup] if cache.key?(markup)
46
+
47
+ cache[markup] = inner_parse(markup, ss, cache).freeze
48
+ else
49
+ inner_parse(markup, ss, nil).freeze
50
+ end
30
51
  end
31
52
 
32
- case markup
33
- when INTEGERS_REGEX
34
- Regexp.last_match(1).to_i
35
- when RANGES_REGEX
36
- RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2))
37
- when FLOATS_REGEX
38
- Regexp.last_match(1).to_f
39
- else
40
- if LITERALS.key?(markup)
41
- LITERALS[markup]
53
+ def inner_parse(markup, ss, cache)
54
+ if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX
55
+ return RangeLookup.parse(
56
+ Regexp.last_match(1),
57
+ Regexp.last_match(2),
58
+ ss,
59
+ cache,
60
+ )
61
+ end
62
+
63
+ if (num = parse_number(markup, ss))
64
+ num
65
+ else
66
+ VariableLookup.parse(markup, ss, cache)
67
+ end
68
+ end
69
+
70
+ def parse_number(markup, ss)
71
+ # check if the markup is simple integer or float
72
+ case markup
73
+ when INTEGER_REGEX
74
+ return Integer(markup, 10)
75
+ when FLOAT_REGEX
76
+ return markup.to_f
77
+ end
78
+
79
+ ss.string = markup
80
+ # the first byte must be a digit or a dash
81
+ byte = ss.scan_byte
82
+
83
+ return false if byte != DASH && (byte < ZERO || byte > NINE)
84
+
85
+ if byte == DASH
86
+ peek_byte = ss.peek_byte
87
+
88
+ # if it starts with a dash, the next byte must be a digit
89
+ return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
90
+ end
91
+
92
+ # The markup could be a float with multiple dots
93
+ first_dot_pos = nil
94
+ num_end_pos = nil
95
+
96
+ while (byte = ss.scan_byte)
97
+ return false if byte != DOT && (byte < ZERO || byte > NINE)
98
+
99
+ # we found our number and now we are just scanning the rest of the string
100
+ next if num_end_pos
101
+
102
+ if byte == DOT
103
+ if first_dot_pos.nil?
104
+ first_dot_pos = ss.pos
105
+ else
106
+ # we found another dot, so we know that the number ends here
107
+ num_end_pos = ss.pos - 1
108
+ end
109
+ end
110
+ end
111
+
112
+ num_end_pos = markup.length if ss.eos?
113
+
114
+ if num_end_pos
115
+ # number ends with a number "123.123"
116
+ markup.byteslice(0, num_end_pos).to_f
42
117
  else
43
- VariableLookup.parse(markup)
118
+ # number ends with a dot "123."
119
+ markup.byteslice(0, first_dot_pos).to_f
44
120
  end
45
121
  end
46
122
  end
data/lib/liquid/lexer.rb CHANGED
@@ -1,66 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "strscan"
4
-
5
3
  module Liquid
6
- class Lexer1
7
- SPECIALS = {
8
- '|' => :pipe,
9
- '.' => :dot,
10
- ':' => :colon,
11
- ',' => :comma,
12
- '[' => :open_square,
13
- ']' => :close_square,
14
- '(' => :open_round,
15
- ')' => :close_round,
16
- '?' => :question,
17
- '-' => :dash,
18
- }.freeze
19
- IDENTIFIER = /[a-zA-Z_][\w-]*\??/
20
- SINGLE_STRING_LITERAL = /'[^\']*'/
21
- DOUBLE_STRING_LITERAL = /"[^\"]*"/
22
- STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL)
23
- NUMBER_LITERAL = /-?\d+(\.\d+)?/
24
- DOTDOT = /\.\./
25
- COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
26
- WHITESPACE_OR_NOTHING = /\s*/
27
-
28
- def initialize(input)
29
- @ss = StringScanner.new(input)
30
- end
31
-
32
- def tokenize
33
- @output = []
34
-
35
- until @ss.eos?
36
- @ss.skip(WHITESPACE_OR_NOTHING)
37
- break if @ss.eos?
38
- tok = if (t = @ss.scan(COMPARISON_OPERATOR))
39
- [:comparison, t]
40
- elsif (t = @ss.scan(STRING_LITERAL))
41
- [:string, t]
42
- elsif (t = @ss.scan(NUMBER_LITERAL))
43
- [:number, t]
44
- elsif (t = @ss.scan(IDENTIFIER))
45
- [:id, t]
46
- elsif (t = @ss.scan(DOTDOT))
47
- [:dotdot, t]
48
- else
49
- c = @ss.getch
50
- if (s = SPECIALS[c])
51
- [s, c]
52
- else
53
- raise SyntaxError, "Unexpected character #{c}"
54
- end
55
- end
56
- @output << tok
57
- end
58
-
59
- @output << [:end_of_string]
60
- end
61
- end
62
-
63
- class Lexer2
4
+ class Lexer
64
5
  CLOSE_ROUND = [:close_round, ")"].freeze
65
6
  CLOSE_SQUARE = [:close_square, "]"].freeze
66
7
  COLON = [:colon, ":"].freeze
@@ -92,6 +33,7 @@ module Liquid
92
33
  SINGLE_COMPARISON_TOKENS = [].tap do |table|
93
34
  table["<".ord] = COMPARISON_LESS_THAN
94
35
  table[">".ord] = COMPARISON_GREATER_THAN
36
+ table.freeze
95
37
  end
96
38
 
97
39
  TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
@@ -103,18 +45,17 @@ module Liquid
103
45
  sub_table["=".ord] = COMPARISION_NOT_EQUAL
104
46
  sub_table.freeze
105
47
  end
48
+ table.freeze
106
49
  end
107
50
 
108
51
  COMPARISON_JUMP_TABLE = [].tap do |table|
109
52
  table["<".ord] = [].tap do |sub_table|
110
53
  sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
111
54
  sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
112
- RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_LESS_THAN }
113
55
  sub_table.freeze
114
56
  end
115
57
  table[">".ord] = [].tap do |sub_table|
116
58
  sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
117
- RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_GREATER_THAN }
118
59
  sub_table.freeze
119
60
  end
120
61
  table.freeze
@@ -157,81 +98,76 @@ module Liquid
157
98
  table.freeze
158
99
  end
159
100
 
160
- def initialize(input)
161
- @ss = StringScanner.new(input)
162
- end
163
-
164
101
  # rubocop:disable Metrics/BlockNesting
165
- def tokenize
166
- @output = []
167
-
168
- until @ss.eos?
169
- @ss.skip(WHITESPACE_OR_NOTHING)
170
-
171
- break if @ss.eos?
172
-
173
- start_pos = @ss.pos
174
- peeked = @ss.peek_byte
175
-
176
- if (special = SPECIAL_TABLE[peeked])
177
- @ss.scan_byte
178
- # Special case for ".."
179
- if special == DOT && @ss.peek_byte == DOT_ORD
180
- @ss.scan_byte
181
- @output << DOTDOT
182
- elsif special == DASH
183
- # Special case for negative numbers
184
- if (peeked_byte = @ss.peek_byte) && NUMBER_TABLE[peeked_byte]
185
- @ss.pos -= 1
186
- @output << [:number, @ss.scan(NUMBER_LITERAL)]
102
+ class << self
103
+ def tokenize(ss)
104
+ output = []
105
+
106
+ until ss.eos?
107
+ ss.skip(WHITESPACE_OR_NOTHING)
108
+
109
+ break if ss.eos?
110
+
111
+ start_pos = ss.pos
112
+ peeked = ss.peek_byte
113
+
114
+ if (special = SPECIAL_TABLE[peeked])
115
+ ss.scan_byte
116
+ # Special case for ".."
117
+ if special == DOT && ss.peek_byte == DOT_ORD
118
+ ss.scan_byte
119
+ output << DOTDOT
120
+ elsif special == DASH
121
+ # Special case for negative numbers
122
+ if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte]
123
+ ss.pos -= 1
124
+ output << [:number, ss.scan(NUMBER_LITERAL)]
125
+ else
126
+ output << special
127
+ end
187
128
  else
188
- @output << special
129
+ output << special
189
130
  end
190
- else
191
- @output << special
192
- end
193
- elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
194
- @ss.scan_byte
195
- if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
196
- @output << found
197
- @ss.scan_byte
198
- else
199
- raise_syntax_error(start_pos)
200
- end
201
- elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
202
- @ss.scan_byte
203
- if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
204
- @output << found
205
- @ss.scan_byte
206
- else
207
- @output << SINGLE_COMPARISON_TOKENS[peeked]
208
- end
209
- else
210
- type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
211
-
212
- if type && (t = @ss.scan(pattern))
213
- # Special case for "contains"
214
- @output << if type == :id && t == "contains" && @output.last&.first != :dot
215
- COMPARISON_CONTAINS
131
+ elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
132
+ ss.scan_byte
133
+ if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
134
+ output << found
135
+ ss.scan_byte
136
+ else
137
+ raise_syntax_error(start_pos, ss)
138
+ end
139
+ elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
140
+ ss.scan_byte
141
+ if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
142
+ output << found
143
+ ss.scan_byte
216
144
  else
217
- [type, t]
145
+ output << SINGLE_COMPARISON_TOKENS[peeked]
218
146
  end
219
147
  else
220
- raise_syntax_error(start_pos)
148
+ type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
149
+
150
+ if type && (t = ss.scan(pattern))
151
+ # Special case for "contains"
152
+ output << if type == :id && t == "contains" && output.last&.first != :dot
153
+ COMPARISON_CONTAINS
154
+ else
155
+ [type, t]
156
+ end
157
+ else
158
+ raise_syntax_error(start_pos, ss)
159
+ end
221
160
  end
222
161
  end
162
+ # rubocop:enable Metrics/BlockNesting
163
+ output << EOS
223
164
  end
224
- # rubocop:enable Metrics/BlockNesting
225
165
 
226
- @output << EOS
227
- end
228
-
229
- def raise_syntax_error(start_pos)
230
- @ss.pos = start_pos
231
- # the character could be a UTF-8 character, use getch to get all the bytes
232
- raise SyntaxError, "Unexpected character #{@ss.getch}"
166
+ def raise_syntax_error(start_pos, ss)
167
+ ss.pos = start_pos
168
+ # the character could be a UTF-8 character, use getch to get all the bytes
169
+ raise SyntaxError, "Unexpected character #{ss.getch}"
170
+ end
233
171
  end
234
172
  end
235
-
236
- Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1
237
173
  end
@@ -12,6 +12,18 @@ module Liquid
12
12
  @locale = @template_options[:locale] ||= I18n.new
13
13
  @warnings = []
14
14
 
15
+ # constructing new StringScanner in Lexer, Tokenizer, etc is expensive
16
+ # This StringScanner will be shared by all of them
17
+ @string_scanner = StringScanner.new("")
18
+
19
+ @expression_cache = if options[:expression_cache].nil?
20
+ {}
21
+ elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=)
22
+ options[:expression_cache]
23
+ elsif options[:expression_cache]
24
+ {}
25
+ end
26
+
15
27
  self.depth = 0
16
28
  self.partial = false
17
29
  end
@@ -24,12 +36,22 @@ module Liquid
24
36
  Liquid::BlockBody.new
25
37
  end
26
38
 
27
- def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false)
28
- Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag)
39
+ def new_parser(input)
40
+ @string_scanner.string = input
41
+ Parser.new(@string_scanner)
42
+ end
43
+
44
+ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
45
+ Tokenizer.new(
46
+ source: source,
47
+ string_scanner: @string_scanner,
48
+ line_number: start_line_number,
49
+ for_liquid_tag: for_liquid_tag,
50
+ )
29
51
  end
30
52
 
31
53
  def parse_expression(markup)
32
- Expression.parse(markup)
54
+ Expression.parse(markup, @string_scanner, @expression_cache)
33
55
  end
34
56
 
35
57
  def partial=(value)
data/lib/liquid/parser.rb CHANGED
@@ -3,8 +3,8 @@
3
3
  module Liquid
4
4
  class Parser
5
5
  def initialize(input)
6
- l = Lexer.new(input)
7
- @tokens = l.tokenize
6
+ ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
7
+ @tokens = Lexer.tokenize(ss)
8
8
  @p = 0 # pointer to current location
9
9
  end
10
10
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Liquid
4
4
  class RangeLookup
5
- def self.parse(start_markup, end_markup)
6
- start_obj = Expression.parse(start_markup)
7
- end_obj = Expression.parse(end_markup)
5
+ def self.parse(start_markup, end_markup, string_scanner, cache = nil)
6
+ start_obj = Expression.parse(start_markup, string_scanner, cache)
7
+ end_obj = Expression.parse(end_markup, string_scanner, cache)
8
8
  if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
9
9
  new(start_obj, end_obj)
10
10
  else