Compare commits

..
Author SHA1 Message Date
Charles-P. ClermontandClaude Opus 4.5 9a3057f8e6 Point liquid-spec to cp-gate-lax-specs-ai branch
This branch gates lax-mode-only specs behind lax_parsing feature:
- shopify_production_recordings: 22 specs
- liquid_ruby: 16 specs
- basics: error regex updated

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-26 16:52:06 -05:00
Charles-P. Clermont f4a5454cd6 Remove warnings system 2026-01-14 09:55:21 -05:00
Charles-P. Clermont 40b1f83f25 Update changelog with planned changes for 6.0.0 2026-01-14 09:55:21 -05:00
Charles-P. Clermont 62400f71d0 Remove :error_mode 2026-01-14 09:55:20 -05:00
Charles-P. Clermont b7fa6a89b3 Rename strict2_parse to parse_markup 2026-01-14 09:54:19 -05:00
Charles-P. Clermont d936ce4ac9 Remove :lax, :strict and :warn error modes 2026-01-14 09:54:19 -05:00
54 changed files with 366 additions and 2019 deletions
+3 -4
View File
@@ -22,7 +22,6 @@ jobs:
}
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
- { ruby: truffleruby, allowed-failure: false }
# Head can have failures due to being in development
- { ruby: head, allowed-failure: true }
@@ -36,7 +35,7 @@ jobs:
name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
@@ -52,7 +51,7 @@ jobs:
BUNDLE_WITH: spec
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
with:
bundler-cache: true
bundler: latest
@@ -67,7 +66,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
with:
bundler-cache: true
- run: bundle exec rake memory_profile:run
+2 -1
View File
@@ -32,6 +32,7 @@ group :test do
end
group :spec do
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main'
# TODO: temporary - using cp-gate-lax-specs-ai branch until lax specs are properly gated
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'cp-gate-lax-specs-ai'
gem 'activesupport', require: false
end
+33
View File
@@ -1,5 +1,38 @@
# Liquid Change Log
## 6.0.0
### Architectural changes
### Features
* (TODO) Add support for boolean expressions everywhere
* As variable output `{{ a or b }}`
* As filter argument `{{ collection | where: 'prop', a or b }}`
* As tag argument `{% render 'snip', enabled: a or b %}`
* As conditional tag argument `{% if cond %}` (extending previous behaviour)
* (TODO) Add support for subexpression prioritization and associativity
* In ascending order of priority:
* Logical: `and`, `or` (right to left)
* Equality: `==`, `!=`, `<>` (left to right)
* Comparison: `>`, `>=`, `<`, `<=`, `contains` (left to right)
- For example, this is now supported
* `{{ a > b == c < d or e == f }}` which is equivalent to
* `{{ ((a > b) == (c < d)) or (e == f) }}`
- (TODO) Add support for parenthesized expressions
* e.g. `(a or b) and c`
### Breaking changes
* The Environment's `error_mode` option has been removed.
* `:warn` is no longer supported
* `:lax` and `lax_parse` is no longer supported
* `:strict` and `strict_parse` is no longer supported
* `strict2_parse` is renamed to `parse_markup`
* The `warnings` system has been removed.
### Migrating from `^5.11.0`
- In custom tags that include `ParserSwitching`, rename `strict2_parse` to `parse_markup`
- Remove code depending on `:error_mode`
## 5.11.0
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
-25
View File
@@ -93,31 +93,6 @@ LIQUID
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`.
### Error Modes
Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted.
Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make
it very hard to debug and can lead to unexpected behaviour.
Liquid also comes with different parsers that can be used when editing templates to give better error messages
when templates are invalid. You can enable this new parser like this:
```ruby
Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
```ruby
Liquid::Template.parse(source, error_mode: :strict)
```
This is useful for doing things like enabling strict mode only in the theme editor.
It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created.
It is also recommended that you use it in the template editors of existing apps to give editors better error messages.
### Undefined variables and filters
By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method.
+4 -39
View File
@@ -33,29 +33,12 @@ task :rubocop do
end
end
desc('runs test suite with lax, strict, and strict2 parsers')
desc('runs test suite')
task :test do
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['base_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end
@@ -78,24 +61,11 @@ task release: :build do
end
namespace :benchmark do
desc "Run the liquid benchmark with lax parsing"
task :lax do
ruby "./performance/benchmark.rb lax"
desc "Run the liquid benchmark"
task :run do
ruby "./performance/benchmark.rb"
end
desc "Run the liquid benchmark with strict parsing"
task :strict do
ruby "./performance/benchmark.rb strict"
end
desc "Run the liquid benchmark with strict2 parsing"
task :strict2 do
ruby "./performance/benchmark.rb strict2"
end
desc "Run the liquid benchmark with lax, strict, and strict2 parsing"
task run: [:lax, :strict, :strict2]
desc "Run unit benchmarks"
namespace :unit do
task :all do
@@ -126,11 +96,6 @@ namespace :profile do
task :run do
ruby "./performance/profile.rb"
end
desc "Run the liquid profile/performance coverage with strict parsing"
task :strict do
ruby "./performance/profile.rb strict"
end
end
namespace :memory_profile do
-1
View File
@@ -52,7 +52,6 @@ end
require "liquid/version"
require "liquid/deprecations"
require "liquid/const"
require "liquid/byte_tables"
require 'liquid/standardfilters'
require 'liquid/file_system'
require 'liquid/parser_switching'
+7 -83
View File
@@ -130,21 +130,16 @@ module Liquid
case
when token.start_with?(TAGSTART)
whitespace_handler(token, parse_context)
# rubocop:disable Metrics/BlockNesting
fast = try_parse_tag_token(token)
if fast
tag_name, markup, newlines = fast
elsif token =~ FullToken
tag_name = Regexp.last_match(2)
markup = Regexp.last_match(4)
newlines = parse_context.line_number ? Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n") : 0
else
unless token =~ FullToken
return handle_invalid_tag_token(token, parse_context, &block)
end
# rubocop:enable Metrics/BlockNesting
tag_name = Regexp.last_match(2)
markup = Regexp.last_match(4)
if parse_context.line_number && newlines > 0
parse_context.line_number += newlines
if parse_context.line_number
# newlines inside the tag should increase the line number,
# particularly important for multiline {% liquid %} tags
parse_context.line_number += Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n")
end
if tag_name == 'liquid'
@@ -265,77 +260,6 @@ module Liquid
BlockBody.raise_missing_variable_terminator(token, parse_context)
end
# Fast path for parsing "{%[-] tag_name markup [-]%}" tag tokens.
# Returns [tag_name, markup, newline_count] or nil.
#
# Accepts tokens where:
# - Tag name is '#' or starts with [a-zA-Z_] followed by \w chars
# (matching TagName = /#|\w+/ exactly — no hyphens, no '?' suffix)
# - Whitespace is spaces, tabs, newlines, \r, \f, \v
# - Whitespace control dashes are at positions 2 and len-3
# Rejects (returns nil → caller falls back to FullToken regex):
# - Tokens shorter than "{%x%}" (4 bytes)
# - Tag names starting with a digit (valid in FullToken but rare)
# - Any structure the byte-walk can't confidently parse
# Fallback: nil return triggers the original `token =~ FullToken` regex
# match in parse_for_document, preserving identical behavior for any
# input the fast path doesn't handle.
def try_parse_tag_token(token)
len = token.bytesize
pos = 2 # skip "{%"
return if pos >= len
pos += 1 if token.getbyte(pos) == ByteTables::DASH
newline_count = 0
# Skip whitespace before tag name, count newlines
while pos < len
b = token.getbyte(pos)
if b == ByteTables::NEWLINE
pos += 1
newline_count += 1
elsif ByteTables::WHITESPACE[b]
pos += 1
else
break
end
end
return if pos >= len
# Scan tag name: '#' or \w+ (matching TagName = /#|\w+/)
name_start = pos
b = token.getbyte(pos)
if b == ByteTables::HASH
pos += 1
elsif ByteTables::IDENT_START[b]
pos += 1
pos += 1 while pos < len && ByteTables::WORD[token.getbyte(pos)]
else
return
end
tag_name = token.byteslice(name_start, pos - name_start)
# Skip whitespace after tag name, count newlines
while pos < len
b = token.getbyte(pos)
if b == ByteTables::NEWLINE
pos += 1
newline_count += 1
elsif ByteTables::WHITESPACE[b]
pos += 1
else
break
end
end
# Markup: everything up to optional '-' before '%}'
markup_end = len - 2 # skip '%}'
markup_end -= 1 if markup_end > pos && token.getbyte(markup_end - 1) == ByteTables::DASH
markup = pos >= markup_end ? "" : token.byteslice(pos, markup_end - pos)
[tag_name, markup, newline_count]
end
# @deprecated Use {.raise_missing_tag_terminator} instead
def raise_missing_tag_terminator(token, parse_context)
BlockBody.raise_missing_tag_terminator(token, parse_context)
-48
View File
@@ -1,48 +0,0 @@
# frozen_string_literal: true
module Liquid
# Pre-computed 256-entry boolean lookup tables for byte classification.
# Built once at load time; used as TABLE[byte] — a single array index
# instead of 3-5 comparison operators per check.
module ByteTables
# [a-zA-Z_] — valid first byte of an identifier
IDENT_START = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
t[95] = true # _
end.freeze
# [a-zA-Z0-9_-] — valid continuation byte of an identifier
IDENT_CONT = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
(48..57).each { |b| t[b] = true } # 0-9
t[95] = true # _
t[45] = true # -
end.freeze
# [a-zA-Z0-9_] — \w equivalent (no hyphen), for tag name scanning
WORD = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
(48..57).each { |b| t[b] = true } # 0-9
t[95] = true # _
end.freeze
# [0-9] — ASCII digit
DIGIT = Array.new(256, false).tap do |t|
(48..57).each { |b| t[b] = true }
end.freeze
# Matches bytes removed by Ruby's String#strip: \x00, \t, \n, \v, \f, \r, space
WHITESPACE = Array.new(256, false).tap do |t|
[0, 9, 10, 11, 12, 13, 32].each { |b| t[b] = true }
end.freeze
# Byte constants for delimiters and punctuation
NEWLINE = 10
DASH = 45 # '-'
DOT = 46 # '.'
HASH = 35 # '#'
end
end
+1 -6
View File
@@ -60,10 +60,6 @@ module Liquid
end
# rubocop:enable Metrics/ParameterLists
def warnings
@warnings ||= []
end
def strainer
@strainer ||= @environment.create_strainer(self, @filters)
end
@@ -157,7 +153,6 @@ module Liquid
subcontext.filters = @filters
subcontext.strainer = nil
subcontext.errors = errors
subcontext.warnings = warnings
subcontext.disabled_tags = @disabled_tags
end
end
@@ -244,7 +239,7 @@ module Liquid
protected
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags
attr_writer :base_scope_depth, :errors, :strainer, :filters, :disabled_tags
private
+1 -9
View File
@@ -4,10 +4,6 @@ module Liquid
# The Environment is the container for all configuration options of Liquid, such as
# the registered tags, filters, and the default error mode.
class Environment
# The default error mode for all templates. This can be overridden on a
# per-template basis.
attr_accessor :error_mode
# The tags that are available to use in the template.
attr_accessor :tags
@@ -33,17 +29,14 @@ module Liquid
# the template.
# @param file_system The default file system that is used
# to load templates from.
# @param error_mode [Symbol] The default error mode for all templates
# (either :strict2, :strict, :warn, or :lax).
# @param exception_renderer [Proc] The exception renderer that is used to
# render exceptions.
# @yieldparam environment [Environment] The environment instance that is being built.
# @return [Environment] The new environment instance.
def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil)
def build(tags: nil, file_system: nil, exception_renderer: nil)
ret = new
ret.tags = tags if tags
ret.file_system = file_system if file_system
ret.error_mode = error_mode if error_mode
ret.exception_renderer = exception_renderer if exception_renderer
yield ret if block_given?
ret.freeze
@@ -75,7 +68,6 @@ module Liquid
# @api private
def initialize
@tags = Tags::STANDARD_TAGS.dup
@error_mode = :lax
@strainer_template = Class.new(StrainerTemplate).tap do |klass|
klass.add_filter(StandardFilters)
end
+49 -71
View File
@@ -11,14 +11,18 @@ module Liquid
'false' => false,
'blank' => '',
'empty' => '',
# in lax mode, minus sign can be a VariableLookup
# For simplicity and performace, we treat it like a literal
'-' => VariableLookup.parse("-", nil).freeze,
}.freeze
DOT = ".".ord
ZERO = "0".ord
NINE = "9".ord
DASH = "-".ord
# Use an atomic group (?>...) to avoid pathological backtracing from
# malicious input as described in https://github.com/Shopify/liquid/issues/1357
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
INTEGER_REGEX = /\A(-?\d+)\z/
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
class << self
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
@@ -28,15 +32,7 @@ module Liquid
def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
# Guard: only call .strip when the first or last byte is whitespace.
# String#strip always allocates a new String, even when there's nothing
# to strip. ByteTables::WHITESPACE matches the same bytes that strip
# removes (space, \t, \n, \v, \f, \r, \x00). When neither end has
# whitespace, we skip the call and avoid ~4,464 allocations per compile.
first = markup.getbyte(0)
if first && (ByteTables::WHITESPACE[first] || ByteTables::WHITESPACE[markup.getbyte(markup.bytesize - 1)])
markup = markup.strip
end
markup = markup.strip # markup can be a frozen string
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
@@ -72,74 +68,56 @@ module Liquid
end
end
# Fast path for number parsing. Accepts:
# - Simple integers: "42", "-7"
# - Simple floats: "3.14", "-0.5"
# - Multi-dot floats (truncated at second dot): "1.2.3" → 1.2
# - Trailing-dot floats: "123." → 123.0
# Rejects (returns nil → caller treats as VariableLookup):
# - Non-numeric input: "hello", ""
# - Inputs with non-digit/non-dot bytes after the number: "1.2.3a"
# Fallback: nil return causes caller to fall through to VariableLookup.parse,
# which is the same path the old regex-based code took on non-match.
def parse_number(markup, _ss = nil)
len = markup.bytesize
return if len == 0
pos = 0
first = markup.getbyte(pos)
if first == ByteTables::DASH
pos += 1
return if pos >= len
return unless ByteTables::DIGIT[markup.getbyte(pos)]
pos += 1
elsif ByteTables::DIGIT[first]
pos += 1
else
return
def parse_number(markup, ss)
# check if the markup is simple integer or float
case markup
when INTEGER_REGEX
return Integer(markup, 10)
when FLOAT_REGEX
return markup.to_f
end
# Scan digits
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
ss.string = markup
# the first byte must be a digit or a dash
byte = ss.scan_byte
# Consumed everything = simple integer
return Integer(markup, 10) if pos == len
return false if byte != DASH && (byte < ZERO || byte > NINE)
# Check for dot — three float cases:
# 1. Simple float: "123.456" → markup.to_f
# 2. Multi-dot: "1.2.3.4" → truncate at second dot → 1.2
# 3. Trailing dot: "123." → truncate before dot → 123.0
return unless markup.getbyte(pos) == ByteTables::DOT
if byte == DASH
peek_byte = ss.peek_byte
dot_pos = pos
pos += 1
digit_start = pos
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
# if it starts with a dash, the next byte must be a digit
return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
end
if pos > digit_start && pos == len
# Case 1: simple float like "123.456"
markup.to_f
elsif pos > digit_start
# Case 2: multi-dot like "1.2.3.4" — find where the numeric
# portion ends. Reject if any non-digit, non-dot byte is found
# (e.g. "1.2.3a" → nil, matching the old regex-based behavior).
num_end = nil
check = pos
while check < len
b = markup.getbyte(check)
if b == ByteTables::DOT
num_end ||= check
elsif !ByteTables::DIGIT[b]
return
# The markup could be a float with multiple dots
first_dot_pos = nil
num_end_pos = nil
while (byte = ss.scan_byte)
return false if byte != DOT && (byte < ZERO || byte > NINE)
# we found our number and now we are just scanning the rest of the string
next if num_end_pos
if byte == DOT
if first_dot_pos.nil?
first_dot_pos = ss.pos
else
# we found another dot, so we know that the number ends here
num_end_pos = ss.pos - 1
end
check += 1
end
markup.byteslice(0, num_end || len).to_f
end
num_end_pos = markup.length if ss.eos?
if num_end_pos
# number ends with a number "123.123"
markup.byteslice(0, num_end_pos).to_f
else
# Case 3: trailing dot like "123."
markup.byteslice(0, dot_pos).to_f
# number ends with a dot "123."
markup.byteslice(0, first_dot_pos).to_f
end
end
end
+8 -15
View File
@@ -3,14 +3,13 @@
module Liquid
class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode, :environment
attr_reader :partial, :environment
def initialize(options = Const::EMPTY_HASH)
@environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new
@warnings = []
@locale = @template_options[:locale] ||= I18n.new
# constructing new StringScanner in Lexer, Tokenizer, etc is expensive
# This StringScanner will be shared by all of them
@@ -55,16 +54,12 @@ module Liquid
end
def parse_expression(markup, safe: false)
if !safe && @error_mode == :strict2
# parse_expression is a widely used API. To maintain backward
# compatibility while raising awareness about strict2 parser standards,
# the safe flag supports API users make a deliberate decision.
#
# In strict2 mode, markup MUST come from a string returned by the parser
# (e.g., parser.expression). We're not calling the parser here to
# prevent redundant parser overhead.
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
end
# markup MUST come from a string returned by the parser
# (e.g., parser.expression). We're not calling the parser here to
# prevent redundant parser overhead. The `safe` opt-in
# exists to ensure it is not accidentally still called with
# the result of a regex.
raise Liquid::InternalError, "unsafe parse_expression cannot be used" unless safe
Expression.parse(markup, @string_scanner, @expression_cache)
end
@@ -72,8 +67,6 @@ module Liquid
def partial=(value)
@partial = value
@options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || @environment.error_mode
end
def partial_options
+5 -64
View File
@@ -2,75 +2,16 @@
module Liquid
module ParserSwitching
# Do not use this.
#
# It's basically doing the same thing the {#parse_with_selected_parser},
# except this will try the strict parser regardless of the error mode,
# and fall back to the lax parser if the error mode is lax or warn,
# except when in strict2 mode where it uses the strict2 parser.
#
# @deprecated Use {#parse_with_selected_parser} instead.
def strict_parse_with_error_mode_fallback(markup)
return strict2_parse_with_error_context(markup) if strict2_mode?
strict_parse_with_error_context(markup)
rescue SyntaxError => e
case parse_context.error_mode
when :rigid
rigid_warn
raise
when :strict2
raise
when :strict
raise
when :warn
parse_context.warnings << e
end
lax_parse(markup)
end
def parse_with_selected_parser(markup)
case parse_context.error_mode
when :rigid then rigid_warn && strict2_parse_with_error_context(markup)
when :strict2 then strict2_parse_with_error_context(markup)
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :warn
begin
strict2_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
end
end
end
def strict2_mode?
parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
parse_markup(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
private
def rigid_warn
Deprecations.warn(':rigid', ':strict2')
end
def strict2_parse_with_error_context(markup)
strict2_parse(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
def strict_parse_with_error_context(markup)
strict_parse(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
def markup_context(markup)
"in \"#{markup.strip}\""
end
+1 -1
View File
@@ -4,7 +4,7 @@ module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = context.registers[:cached_partials]
cache_key = "#{template_name}:#{parse_context.error_mode}"
cache_key = template_name.to_s
cached = cached_partials[cache_key]
return cached if cached
+5 -22
View File
@@ -2,39 +2,24 @@
module Liquid
class ResourceLimits
attr_accessor :render_length_limit,
:render_score_limit,
:assign_score_limit,
:cumulative_render_score_limit,
:cumulative_assign_score_limit
attr_reader :render_score,
:assign_score,
:cumulative_render_score,
:cumulative_assign_score
attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
attr_reader :render_score, :assign_score
def initialize(limits)
@render_length_limit = limits[:render_length_limit]
@render_score_limit = limits[:render_score_limit]
@assign_score_limit = limits[:assign_score_limit]
@cumulative_render_score_limit = limits[:cumulative_render_score_limit]
@cumulative_assign_score_limit = limits[:cumulative_assign_score_limit]
@cumulative_render_score = 0
@cumulative_assign_score = 0
@render_length_limit = limits[:render_length_limit]
@render_score_limit = limits[:render_score_limit]
@assign_score_limit = limits[:assign_score_limit]
reset
end
def increment_render_score(amount)
@render_score += amount
@cumulative_render_score += amount
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
end
def increment_assign_score(amount)
@assign_score += amount
@cumulative_assign_score += amount
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
end
# update either render_length or assign_score based on whether or not the writes are captured
@@ -62,8 +47,6 @@ module Liquid
@reached_limit = false
@last_capture_length = nil
@render_score = @assign_score = 0
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
end
def with_capture
+7 -29
View File
@@ -8,19 +8,10 @@ module Liquid
MAX_I32 = (1 << 31) - 1
private_constant :MAX_I32
supports_64bit_indices = begin
[][1 << 33, 1 << 33]
true
rescue RangeError
false
end
INDEX_RANGE = if supports_64bit_indices
(-(1 << 63))..((1 << 63) - 1)
else
(-(1 << 31))..((1 << 31) - 1)
end
private_constant :INDEX_RANGE
MIN_I64 = -(1 << 63)
MAX_I64 = (1 << 63) - 1
I64_RANGE = MIN_I64..MAX_I64
private_constant :MIN_I64, :MAX_I64, :I64_RANGE
HTML_ESCAPE = {
'&' => '&amp;',
@@ -223,11 +214,11 @@ module Liquid
Utils.to_s(input).slice(offset, length) || ''
end
rescue RangeError
if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset)
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
raise # unexpected error
end
offset = offset.clamp(INDEX_RANGE)
length = length.clamp(INDEX_RANGE)
offset = offset.clamp(I64_RANGE)
length = length.clamp(I64_RANGE)
retry
end
end
@@ -302,19 +293,6 @@ module Liquid
input.split(pattern)
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
# @liquid_summary
# Removes leading and trailing whitespace and collapses consecutive whitespace to a single space.
# @liquid_syntax string | squish
# @liquid_return [string]
def squish(input)
return if input.nil?
Utils.to_s(input).strip.gsub(/\s+/, ' ')
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
+3 -36
View File
@@ -23,9 +23,6 @@ module Liquid
# @liquid_syntax_keyword second_expression An expression to be rendered when the variable's value matches `second_value`.
# @liquid_syntax_keyword third_expression An expression to be rendered when the variable's value has no match.
class Case < Block
Syntax = /(#{QuotedFragment})/o
WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/om
attr_reader :blocks, :left
def initialize(tag_name, markup, options)
@@ -86,35 +83,19 @@ module Liquid
private
def strict2_parse(markup)
def parse_markup(markup)
parser = @parse_context.new_parser(markup)
@left = safe_parse_expression(parser)
parser.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
end
def record_when_condition(markup)
body = new_body
if strict2_mode?
parse_strict2_when(markup, body)
else
parse_lax_when(markup, body)
end
parse_when(markup, body)
end
def parse_strict2_when(markup, body)
def parse_when(markup, body)
parser = @parse_context.new_parser(markup)
loop do
@@ -129,20 +110,6 @@ module Liquid
parser.consume(:end_of_string)
end
def parse_lax_when(markup, body)
while markup
unless markup =~ WhenSyntax
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
end
markup = Regexp.last_match(2)
block = Condition.new(@left, '==', Condition.parse_expression(parse_context, Regexp.last_match(1)))
block.attach(body)
@blocks << block
end
end
def record_else_condition(markup)
unless markup.strip.empty?
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_else")
+1 -32
View File
@@ -15,8 +15,6 @@ module Liquid
# @liquid_syntax
# {% cycle string, string, ... %}
class Cycle < Tag
SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
attr_reader :variables
@@ -56,7 +54,7 @@ module Liquid
private
# cycle [name:] expression(, expression)*
def strict2_parse(markup)
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@variables = []
@@ -91,35 +89,6 @@ module Liquid
end
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
case markup
when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2))
@name = parse_expression(Regexp.last_match(1))
@is_named = true
when SimpleSyntax
@variables = variables_from_string(markup)
@name = @variables.to_s
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
end
def variables_from_string(markup)
markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o
next unless Regexp.last_match(1)
var = parse_expression(Regexp.last_match(1))
maybe_dup_lookup(var)
end.compact
end
# For backwards compatibility, whenever a lookup is used in an unnamed cycle,
# we make it so that the @variables.to_s produces different strings for cycles
# called with the same arguments (since @variables.to_s is used as the cycle counter key)
+1 -22
View File
@@ -25,8 +25,6 @@ module Liquid
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
# @liquid_optional_param reversed [untyped] Iterate in reverse order.
class For < Block
Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o
attr_reader :collection_name, :variable_name, :limit, :from
def initialize(tag_name, markup, options)
@@ -72,22 +70,7 @@ module Liquid
protected
def lax_parse(markup)
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
collection_name = Regexp.last_match(2)
@reversed = !!Regexp.last_match(3)
@name = "#{@variable_name}-#{collection_name}"
@collection_name = parse_expression(collection_name)
markup.scan(TagAttributes) do |key, value|
set_attribute(key, value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.for")
end
end
def strict_parse(markup)
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
@@ -111,10 +94,6 @@ module Liquid
private
def strict2_parse(markup)
strict_parse(markup)
end
def collection_segment(context)
offsets = context.registers[:for] ||= {}
+1 -29
View File
@@ -14,10 +14,6 @@ module Liquid
# @liquid_syntax_keyword condition The condition to evaluate.
# @liquid_syntax_keyword expression The expression to render if the condition is met.
class If < Block
Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o
ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o
BOOLEAN_OPERATORS = %w(and or).freeze
attr_reader :blocks
def initialize(tag_name, markup, options)
@@ -66,10 +62,6 @@ module Liquid
private
def strict2_parse(markup)
strict_parse(markup)
end
def push_block(tag, markup)
block = if tag == 'else'
ElseCondition.new
@@ -85,27 +77,7 @@ module Liquid
Condition.parse_expression(parse_context, markup, safe: safe)
end
def lax_parse(markup)
expressions = markup.scan(ExpressionsAndOperators)
raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop =~ Syntax
condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3)))
until expressions.empty?
operator = expressions.pop.to_s.strip
raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop.to_s =~ Syntax
new_condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3)))
raise SyntaxError, options[:locale].t("errors.syntax.if") unless BOOLEAN_OPERATORS.include?(operator)
new_condition.send(operator, condition)
condition = new_condition
end
condition
end
def strict_parse(markup)
def parse_markup(markup)
p = @parse_context.new_parser(markup)
condition = parse_binary_comparisons(p)
p.consume(:end_of_string)
+1 -27
View File
@@ -20,9 +20,6 @@ module Liquid
class Include < Tag
prepend Tag::Disableable
SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
Syntax = SYNTAX
attr_reader :template_name_expr, :variable_name_expr, :attributes
def initialize(tag_name, markup, options)
@@ -84,7 +81,7 @@ module Liquid
alias_method :parse_context, :options
private :parse_context
def strict2_parse(markup)
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = safe_parse_expression(p)
@@ -104,29 +101,6 @@ module Liquid
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ SYNTAX
template_name = Regexp.last_match(1)
variable_name = Regexp.last_match(3)
@alias_name = Regexp.last_match(5)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.include")
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+3 -26
View File
@@ -27,7 +27,6 @@ module Liquid
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
class Render < Tag
FOR = 'for'
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
disable_tags "include"
@@ -85,10 +84,10 @@ module Liquid
end
# render (string) (with|for expression)? (as id)? (key: value)*
def strict2_parse(markup)
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = parse_expression(strict2_template_name(p), safe: true)
@template_name_expr = parse_expression(template_name(p), safe: true)
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = safe_parse_expression(p) if with_or_for
@alias_name = p.consume(:id) if p.id?("as")
@@ -107,32 +106,10 @@ module Liquid
p.consume(:end_of_string)
end
def strict2_template_name(p)
def template_name(p)
p.consume(:string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+1 -19
View File
@@ -24,7 +24,6 @@ module Liquid
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
class TableRow < Block
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
attr_reader :variable_name, :collection_name, :attributes
@@ -34,7 +33,7 @@ module Liquid
parse_with_selected_parser(markup)
end
def strict2_parse(markup)
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
@@ -62,23 +61,6 @@ module Liquid
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
@collection_name = parse_expression(Regexp.last_match(2))
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.table_row")
end
end
def render_to_output_buffer(context, output)
(collection = context.evaluate(@collection_name)) || (return '')
+1 -16
View File
@@ -16,25 +16,11 @@ module Liquid
#
class Template
attr_accessor :root, :name
attr_reader :resource_limits, :warnings
attr_reader :resource_limits
attr_reader :profiler
class << self
# Sets how strict the parser should be.
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
# :warn is the default and will give deprecation warnings when invalid syntax is used.
# :strict enforces correct syntax for most tags
# :strict2 enforces correct syntax for all tags
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
end
def error_mode
Environment.default.error_mode
end
def default_exception_renderer=(renderer)
Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=")
Environment.default.exception_renderer = renderer
@@ -223,7 +209,6 @@ module Liquid
ParseContext.new(opts)
end
@warnings = parse_context.warnings
parse_context
end
+4 -61
View File
@@ -30,7 +30,7 @@ module Liquid
@parse_context = parse_context
@line_number = parse_context.line_number
strict_parse_with_error_mode_fallback(markup)
parse_with_selected_parser(markup)
end
def raw
@@ -41,58 +41,17 @@ module Liquid
"in \"{{#{markup}}}\""
end
def lax_parse(markup)
@filters = []
return unless markup =~ MarkupWithQuotedFragment
name_markup = Regexp.last_match(1)
filter_markup = Regexp.last_match(2)
@name = parse_context.parse_expression(name_markup)
if filter_markup =~ FilterMarkupRegex
filters = Regexp.last_match(1).scan(FilterParser)
filters.each do |f|
next unless f =~ /\w+/
filtername = Regexp.last_match(0)
filterargs = f.scan(FilterArgsRegex).flatten
@filters << lax_parse_filter_expressions(filtername, filterargs)
end
end
end
def strict_parse(markup)
def parse_markup(markup)
@filters = []
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
while p.consume?(:pipe)
filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << lax_parse_filter_expressions(filtername, filterargs)
end
@filters << parse_filter_expressions(p) while p.consume?(:pipe)
p.consume(:end_of_string)
end
def strict2_parse(markup)
@filters = []
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
@filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe)
p.consume(:end_of_string)
end
def parse_filterargs(p)
# first argument
filterargs = [p.argument]
# followed by comma separated others
filterargs << p.argument while p.consume?(:comma)
filterargs
end
def render(context)
obj = context.evaluate(@name)
@@ -133,22 +92,6 @@ module Liquid
private
def lax_parse_filter_expressions(filter_name, unparsed_args)
filter_args = []
keyword_args = nil
unparsed_args.each do |a|
if (matches = a.match(JustTagAttributes))
keyword_args ||= {}
keyword_args[matches[1]] = parse_context.parse_expression(matches[2])
else
filter_args << parse_context.parse_expression(a)
end
end
result = [filter_name, filter_args]
result << keyword_args if keyword_args
result
end
# Surprisingly, positional and keyword arguments can be mixed.
#
# filter = filtername [":" filterargs?]
@@ -156,7 +99,7 @@ module Liquid
# argument = (positional_argument | keyword_argument)
# positional_argument = expression
# keyword_argument = id ":" expression
def strict2_parse_filter_expressions(p)
def parse_filter_expressions(p)
filtername = p.consume(:id)
filter_args = []
keyword_args = {}
-44
View File
@@ -4,25 +4,6 @@ module Liquid
class VariableLookup
COMMAND_METHODS = ['size', 'first', 'last'].freeze
# Matches simple identifier chains: name(.name)* with no brackets/quotes
SIMPLE_LOOKUP_RE = /\A[\w-]+\??(?:\.[\w-]+\??)*\z/
# Returns true when markup is a simple dotted identifier chain that the
# fast path in initialize can handle. Accepts:
# - Single names: "product", "item"
# - Dotted chains: "product.title", "cart.items.first"
# - Question-mark suffixes: "product.available?"
# - Hyphens in names: "my-var.some-field"
# Rejects (falls through to VariableParser regex):
# - Bracket lookups: "product[0]", "hash['key']"
# - Quoted strings, empty input, leading/trailing dots
# Fallback: when this returns false, initialize uses the original
# markup.scan(VariableParser) path — behavior is identical to
# the pre-optimization code for any input the fast path rejects.
def self.simple_lookup?(markup)
markup.bytesize > 0 && markup.match?(SIMPLE_LOOKUP_RE)
end
attr_reader :name, :lookups
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
@@ -30,31 +11,6 @@ module Liquid
end
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
if self.class.simple_lookup?(markup)
dot_pos = markup.index('.')
if dot_pos.nil?
@name = markup
@lookups = Const::EMPTY_ARRAY
@command_flags = 0
return
end
@name = markup.byteslice(0, dot_pos)
@lookups = []
@command_flags = 0
pos = dot_pos + 1
len = markup.bytesize
while pos < len
seg_start = pos
pos += 1 while pos < len && markup.getbyte(pos) != ByteTables::DOT
seg = markup.byteslice(seg_start, pos - seg_start)
@command_flags |= 1 << @lookups.length if COMMAND_METHODS.include?(seg)
@lookups << seg
pos += 1 # skip dot
end
return
end
lookups = markup.scan(VariableParser)
name = lookups.shift
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.12.0"
VERSION = "5.11.0"
end
-62
View File
@@ -1,62 +0,0 @@
# frozen_string_literal: true
# Quick benchmark: measures parse µs, render µs, and object allocations
# Outputs machine-readable metrics to stdout
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
runner = ThemeRunner.new
# Warmup — enough iterations for YJIT to fully optimize hot paths
20.times { runner.compile }
20.times { runner.render }
GC.start
GC.compact if GC.respond_to?(:compact)
# Measure parse
parse_times = []
10.times do
GC.disable
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
runner.compile
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
GC.enable
GC.start
parse_times << (t1 - t0) * 1_000_000 # µs
end
# Measure render
render_times = []
10.times do
GC.disable
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
runner.render
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
GC.enable
GC.start
render_times << (t1 - t0) * 1_000_000 # µs
end
# Measure object allocations for one parse+render cycle
require 'objspace'
GC.start
GC.disable
before = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
runner.compile
runner.render
after = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
GC.enable
allocations = after - before
parse_us = parse_times.min.round(0)
render_us = render_times.min.round(0)
combined_us = parse_us + render_us
puts "RESULTS"
puts "parse_us=#{parse_us}"
puts "render_us=#{render_us}"
puts "combined_us=#{combined_us}"
puts "allocations=#{allocations}"
-1
View File
@@ -4,7 +4,6 @@ require 'benchmark/ips'
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
-2
View File
@@ -53,8 +53,6 @@ class Profiler
end
end
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
runner = ThemeRunner.new
Profiler.run do |x|
x.profile('parse') { runner.compile }
-1
View File
@@ -3,7 +3,6 @@
require 'stackprof'
require_relative 'theme_runner'
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
profiler.run
-34
View File
@@ -1,34 +0,0 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with lax parsing mode
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
LiquidSpec.configure do |config|
config.features = [:core, :lax_parsing]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Force lax mode
options = options.merge(error_mode: :lax)
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+1 -3
View File
@@ -1,6 +1,6 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport
# Liquid Spec Adapter for Shopify/liquid with YJIT + ActiveSupport
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb
@@ -20,8 +20,6 @@ end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Force strict mode
options = { error_mode: :strict }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
+2 -4
View File
@@ -39,13 +39,11 @@ class AssignTest < Minitest::Test
assert_match_syntax_error(/assign/, '{% assign foo not values %}.')
end
def test_assign_uses_error_mode
def test_assign_throws_on_unsupported_syntax
assert_match_syntax_error(
"Expected dotdot but found pipe in ",
"Expected dotdot but found pipe",
"{% assign foo = ('X' | downcase) %}",
error_mode: :strict,
)
assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax)
end
def test_expression_with_whitespace_in_square_brackets
+3 -5
View File
@@ -632,11 +632,9 @@ class ContextTest < Minitest::Test
end
def test_has_key_will_not_add_an_error_for_missing_keys
with_error_modes(:strict) do
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
end
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
end
def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled
+4 -51
View File
@@ -67,20 +67,11 @@ class ErrorHandlingTest < Minitest::Test
end
def test_unrecognized_operator
with_error_modes(:strict) do
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
end
def test_lax_unrecognized_operator
template = Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :lax)
assert_equal(' Liquid error: Unknown operator =! ', template.render)
assert_equal(1, template.errors.size)
assert_equal(Liquid::ArgumentError, template.errors.first.class)
end
def test_with_line_numbers_adds_numbers_to_parser_errors
source = <<~LIQUID
foobar
@@ -104,25 +95,6 @@ class ErrorHandlingTest < Minitest::Test
assert_match_syntax_error(/Liquid syntax error \(line 3\)/, source)
end
def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors
template = Liquid::Template.parse(
'
foobar
{% if 1 =! 2 %}ok{% endif %}
bla
',
error_mode: :warn,
line_numbers: true,
)
assert_equal(
['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'],
template.warnings.map(&:message),
)
end
def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors
err = assert_raises(SyntaxError) do
Liquid::Template.parse(
@@ -133,7 +105,6 @@ class ErrorHandlingTest < Minitest::Test
bla
',
error_mode: :strict,
line_numbers: true,
)
end
@@ -157,34 +128,16 @@ class ErrorHandlingTest < Minitest::Test
def test_strict_error_messages
err = assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :strict)
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
assert_equal('Liquid syntax error: Unexpected character = in "1 =! 2"', err.message)
err = assert_raises(SyntaxError) do
Liquid::Template.parse('{{%%%}}', error_mode: :strict)
Liquid::Template.parse('{{%%%}}')
end
assert_equal('Liquid syntax error: Unexpected character % in "{{%%%}}"', err.message)
end
def test_warnings
template = Liquid::Template.parse('{% if ~~~ %}{{%%%}}{% else %}{{ hello. }}{% endif %}', error_mode: :warn)
assert_equal(3, template.warnings.size)
assert_equal('Unexpected character ~ in "~~~"', template.warnings[0].to_s(false))
assert_equal('Unexpected character % in "{{%%%}}"', template.warnings[1].to_s(false))
assert_equal('Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].to_s(false))
assert_equal('', template.render)
end
def test_warning_line_numbers
template = Liquid::Template.parse("{% if ~~~ %}\n{{%%%}}{% else %}\n{{ hello. }}{% endif %}", error_mode: :warn, line_numbers: true)
assert_equal('Liquid syntax error (line 1): Unexpected character ~ in "~~~"', template.warnings[0].message)
assert_equal('Liquid syntax error (line 2): Unexpected character % in "{{%%%}}"', template.warnings[1].message)
assert_equal('Liquid syntax error (line 3): Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].message)
assert_equal(3, template.warnings.size)
assert_equal([1, 2, 3], template.warnings.map(&:line_number))
end
# Liquid should not catch Exceptions that are not subclasses of StandardError, like Interrupt and NoMemoryError
def test_exceptions_propagate
assert_raises(Exception) do
-18
View File
@@ -27,11 +27,6 @@ class ExpressionTest < Minitest::Test
assert_template_result("-17.42", "{{ -17.42 }}")
assert_template_result("2.5", "{{ 2.5 }}")
with_error_modes(:lax) do
assert_expression_result(0.0, "0.....5")
assert_expression_result(0.0, "-0..1")
end
assert_expression_result(1.5, "1.5")
# this is a unfortunate quirky behavior of Liquid
@@ -56,19 +51,6 @@ class ExpressionTest < Minitest::Test
)
end
def test_quirky_negative_sign_expression_markup
result = Expression.parse("-", nil)
assert(result.is_a?(VariableLookup))
assert_equal("-", result.name)
# for this template, the expression markup is "-"
assert_template_result(
"",
"{{ - 'theme.css' - }}",
error_mode: :lax,
)
end
def test_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
+10 -89
View File
@@ -31,58 +31,25 @@ class ParsingQuirksTest < Minitest::Test
def test_error_on_empty_filter
assert(Template.parse("{{test}}"))
with_error_modes(:lax) do
assert(Template.parse("{{|test}}"))
end
with_error_modes(:strict) do
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
end
assert_raises(Liquid::SyntaxError) { Template.parse("{{|test}}") }
assert_raises(Liquid::SyntaxError) { Template.parse("{{test |a|b|}}") }
end
def test_meaningless_parens_error
with_error_modes(:strict) do
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
def test_unexpected_characters_syntax_error
with_error_modes(:strict) do
assert_raises(SyntaxError) do
markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
assert_raises(SyntaxError) do
markup = "false || true"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
end
def test_no_error_on_lax_empty_filter
assert(Template.parse("{{test |a|b|}}", error_mode: :lax))
assert(Template.parse("{{test}}", error_mode: :lax))
assert(Template.parse("{{|test|}}", error_mode: :lax))
end
def test_meaningless_parens_lax
with_error_modes(:lax) do
assigns = { 'b' => 'bar', 'c' => 'baz' }
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
end
end
def test_unexpected_characters_silently_eat_logic_lax
with_error_modes(:lax) do
assert_raises(SyntaxError) do
markup = "true && false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
Template.parse("{% if #{markup} %} YES {% endif %}")
end
assert_raises(SyntaxError) do
markup = "false || true"
assert_template_result('', "{% if #{markup} %} YES {% endif %}")
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
@@ -92,32 +59,6 @@ class ParsingQuirksTest < Minitest::Test
end
end
def test_unanchored_filter_arguments
with_error_modes(:lax) do
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
assert_template_result('x', "{{ 'X' | downcase) }}")
# After the messed up quotes a filter without parameters (reverse) should work
# but one with parameters (remove) shouldn't be detected.
assert_template_result('here', "{{ 'hi there' | split:\"t\"\" | reverse | first}}")
assert_template_result('hi ', "{{ 'hi there' | split:\"t\"\" | remove:\"i\" | first}}")
end
end
def test_invalid_variables_work
with_error_modes(:lax) do
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
end
end
def test_extra_dots_in_ranges
with_error_modes(:lax) do
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end
end
def test_blank_variable_markup
assert_template_result('', "{{}}")
end
@@ -131,24 +72,4 @@ class ParsingQuirksTest < Minitest::Test
def test_contains_in_id
assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true })
end
def test_incomplete_expression
with_error_modes(:lax) do
assert_template_result("false", "{{ false - }}")
assert_template_result("false", "{{ false > }}")
assert_template_result("false", "{{ false < }}")
assert_template_result("false", "{{ false = }}")
assert_template_result("false", "{{ false ! }}")
assert_template_result("false", "{{ false 1 }}")
assert_template_result("false", "{{ false a }}")
assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false =\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false !\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false 1\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false a\n%}{{ foo }}")
end
end
end # ParsingQuirksTest
+21 -28
View File
@@ -44,39 +44,32 @@ class SecurityTest < Minitest::Test
end
def test_does_not_permanently_add_filters_to_symbol_table
assert_no_new_symbols do
# MRI imprecisely marks objects found on the C stack, which can result
# in uninitialized memory being marked. This can even result in the test failing
# deterministically for a given compilation of ruby. Using a separate thread will
# keep these writes of the symbol pointer on a separate stack that will be garbage
# collected after Thread#join.
Thread.new do
test = %( {{ "some_string" | a_bad_filter }} )
Template.parse(test).render!
nil
end.join
current_symbols = Symbol.all_symbols
GC.start
end
# MRI imprecisely marks objects found on the C stack, which can result
# in uninitialized memory being marked. This can even result in the test failing
# deterministically for a given compilation of ruby. Using a separate thread will
# keep these writes of the symbol pointer on a separate stack that will be garbage
# collected after Thread#join.
Thread.new do
test = %( {{ "some_string" | a_bad_filter }} )
Template.parse(test).render!
nil
end.join
GC.start
assert_equal([], Symbol.all_symbols - current_symbols)
end
def test_does_not_add_drop_methods_to_symbol_table
assert_no_new_symbols do
assigns = { 'drop' => Drop.new }
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
end
end
def assert_no_new_symbols
# Run once to trigger any first-time initialization which might create some symbols,
# for example autoload or lazy method parsing might create symbols on first execution.
yield
# Ensure no new symbols for further runs, i.e. the code does not leak symbols
current_symbols = Symbol.all_symbols
yield
assigns = { 'drop' => Drop.new }
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
assert_equal([], Symbol.all_symbols - current_symbols)
end
-9
View File
@@ -164,13 +164,6 @@ class StandardFiltersTest < Minitest::Test
assert_equal(['A', 'Z'], @filters.split('A1Z', 1))
end
def test_squish_filter
assert_equal("foo bar boo", Liquid::Template.parse(%({{ " foo bar
\t boo " | squish }})).render)
assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render)
assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render)
end
def test_escape
assert_equal('&lt;strong&gt;', @filters.escape('<strong>'))
assert_equal('1', @filters.escape(1))
@@ -1181,8 +1174,6 @@ class StandardFiltersTest < Minitest::Test
end
def test_all_filters_never_raise_non_liquid_exception
skip("too slow on non-CRuby due to many exceptions") unless RUBY_ENGINE == 'ruby'
test_drop = TestDrop.new(value: "test")
test_drop.context = Context.new
test_enum = TestEnumerable.new
+22 -51
View File
@@ -91,28 +91,21 @@ class CycleTagTest < Minitest::Test
assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message)
end
def test_cycle_tag_with_error_mode
def test_cycle_tag_unsupported_legacy_quirk
# QuotedFragment is more permissive than what Parser#expression allows.
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
template2 = "{% cycle .5: 'a', 'b' %}"
with_error_modes(:lax, :strict) do
assert_template_result("b", template1)
assert_template_result("a", template2)
end
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
with_error_modes(:strict2) do
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
end
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
end
def test_cycle_with_trailing_elements
def test_cycle_with_trailing_elements_legacy_syntax
assignments = "{% assign a = 'A' %}{% assign n = 'N' %}"
template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}"
@@ -121,29 +114,19 @@ class CycleTagTest < Minitest::Test
template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}"
template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}"
with_error_modes(:lax, :strict) do
assert_template_result("a", template1)
assert_template_result("a", template2)
assert_template_result("a", template3)
assert_template_result("N", template4)
assert_template_result("N", template5)
end
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
with_error_modes(:strict2) do
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
expected_error = /Expected end_of_string but found/
expected_error = /Expected end_of_string but found/
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
assert_match(expected_error, error3.message)
assert_match(expected_error, error4.message)
assert_match(expected_error, error5.message)
end
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
assert_match(expected_error, error3.message)
assert_match(expected_error, error4.message)
assert_match(expected_error, error5.message)
end
def test_cycle_name_with_invalid_expression
@@ -153,14 +136,8 @@ class CycleTagTest < Minitest::Test
{% endfor %}
LIQUID
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_cycle_variable_with_invalid_expression
@@ -170,13 +147,7 @@ class CycleTagTest < Minitest::Test
{% endfor %}
LIQUID
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
+16 -50
View File
@@ -204,23 +204,13 @@ class IncludeTagTest < Minitest::Test
)
end
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
)
end
with_error_modes(:strict2) do
assert_syntax_error(
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% include "snippet" | filter %}',
)
end
def test_parsing_errors_for_legacy_quirk
assert_syntax_error(
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% include "snippet" | filter %}',
)
end
def test_optional_commas
@@ -301,16 +291,10 @@ class IncludeTagTest < Minitest::Test
env = Liquid::Environment.build(file_system: TestFileSystem.new)
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
end
with_error_modes(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
Template.parse("{% include template %}", environment: env).render!("template" => '{{ "X" || downcase }}')
end
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
end
with_error_modes(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
Template.parse("{% include template %}", include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
end
end
@@ -365,7 +349,7 @@ class IncludeTagTest < Minitest::Test
file_system: StubFileSystem.new('simple' => 'simple'),
)
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env)
template = Liquid::Template.parse("{% include 'simple' %}", environment: env)
template.render(nil, strict_variables: true)
assert_equal([], template.errors)
@@ -404,39 +388,21 @@ class IncludeTagTest < Minitest::Test
def test_include_template_with_invalid_expression
template = "{% include foo=>bar %}"
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_include_with_invalid_expression
template = '{% include "snippet" with foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_include_attribute_with_invalid_expression
template = '{% include "snippet", key: foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end # IncludeTagTest
+11 -35
View File
@@ -105,23 +105,13 @@ class RenderTagTest < Minitest::Test
assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
end
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
)
end
with_error_modes(:strict2) do
assert_syntax_error(
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% render "snippet" | filter %}',
)
end
def test_parsing_errors_legacy_syntax
assert_syntax_error(
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% render "snippet" | filter %}',
)
end
def test_optional_commas
@@ -317,27 +307,13 @@ class RenderTagTest < Minitest::Test
def test_render_with_invalid_expression
template = '{% render "snippet" with foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_render_attribute_with_invalid_expression
template = '{% render "snippet", key: foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
+32 -102
View File
@@ -188,29 +188,6 @@ class TableRowTest < Minitest::Test
assert_template_result(expected_output, template)
end
def test_table_row_renders_correct_error_message_for_invalid_parameters
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
error_mode: :warn,
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
error_mode: :warn,
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
render_errors: true,
error_mode: :warn,
)
end
def test_table_row_handles_interrupts
assert_template_result(
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td></tr>\n",
@@ -259,7 +236,7 @@ class TableRowTest < Minitest::Test
)
end
def test_tablerow_with_cols_attribute_in_strict2_mode
def test_tablerow_with_cols_attribute
template = <<~LIQUID.chomp
{% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
LIQUID
@@ -270,12 +247,10 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">4</td><td class="col2">5</td><td class="col3">6</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
assert_template_result(expected, template)
end
def test_tablerow_with_limit_attribute_in_strict2_mode
def test_tablerow_with_limit_attribute
template = <<~LIQUID.chomp
{% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
LIQUID
@@ -285,12 +260,10 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
assert_template_result(expected, template)
end
def test_tablerow_with_offset_attribute_in_strict2_mode
def test_tablerow_with_offset_attribute
template = <<~LIQUID.chomp
{% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
LIQUID
@@ -300,12 +273,10 @@ class TableRowTest < Minitest::Test
<td class="col1">3</td><td class="col2">4</td><td class="col3">5</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
assert_template_result(expected, template)
end
def test_tablerow_with_range_attribute_in_strict2_mode
def test_tablerow_with_range_attribute
template = <<~LIQUID.chomp
{% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
LIQUID
@@ -315,12 +286,10 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
assert_template_result(expected, template)
end
def test_tablerow_with_multiple_attributes_in_strict2_mode
def test_tablerow_with_multiple_attributes
template = <<~LIQUID.chomp
{% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %}
LIQUID
@@ -331,12 +300,10 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">4</td><td class="col2">5</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
assert_template_result(expected, template)
end
def test_tablerow_with_variable_collection_in_strict2_mode
def test_tablerow_with_variable_collection
template = <<~LIQUID.chomp
{% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -347,12 +314,10 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
end
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
end
def test_tablerow_with_dotted_access_in_strict2_mode
def test_tablerow_with_dotted_access
template = <<~LIQUID.chomp
{% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -363,12 +328,10 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
end
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
end
def test_tablerow_with_bracketed_access_in_strict2_mode
def test_tablerow_with_bracketed_access
template = <<~LIQUID.chomp
{% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -378,12 +341,10 @@ class TableRowTest < Minitest::Test
<td class="col1">10</td><td class="col2">20</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
end
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
end
def test_tablerow_without_attributes_in_strict2_mode
def test_tablerow_without_attributes
template = <<~LIQUID.chomp
{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
LIQUID
@@ -393,30 +354,24 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
assert_template_result(expected, template)
end
def test_tablerow_without_in_keyword_in_strict2_mode
def test_tablerow_without_in_keyword
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
end
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
end
def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode
def test_tablerow_with_multiple_invalid_attributes_reports_first
template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
end
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
end
def test_tablerow_with_empty_collection_in_strict2_mode
def test_tablerow_with_empty_collection
template = <<~LIQUID.chomp
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
LIQUID
@@ -426,43 +381,18 @@ class TableRowTest < Minitest::Test
</tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'empty_array' => [] })
end
assert_template_result(expected, template, { 'empty_array' => [] })
end
def test_tablerow_with_invalid_attribute_strict_vs_strict2
def test_tablerow_with_invalid_attribute
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td><td class="col4">4</td><td class="col5">5</td></tr>
OUTPUT
with_error_modes(:lax, :strict) do
assert_template_result(expected, template)
end
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
end
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
end
def test_tablerow_with_invalid_expression_strict_vs_strict2
def test_tablerow_with_invalid_expression
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
with_error_modes(:lax, :strict) do
expected = <<~OUTPUT
<tr class="row1">
</tr>
OUTPUT
assert_template_result(expected, template)
end
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
+1 -91
View File
@@ -44,16 +44,6 @@ class TemplateTest < Minitest::Test
assert_equal('from instance assigns', t.parse("{{ foo }}").render!)
end
def test_warnings_is_not_exponential_time
str = "false"
100.times do
str = "{% if true %}true{% else %}#{str}{% endif %}"
end
t = Template.parse(str)
assert_equal([], Timeout.timeout(1) { t.warnings })
end
def test_instance_assigns_persist_on_same_template_parsing_between_renders
t = Template.new.parse("{{ foo }}{% assign foo = 'foo' %}{{ foo }}")
assert_equal('foo', t.render!)
@@ -179,86 +169,6 @@ class TemplateTest < Minitest::Test
assert_equal("すごい", t.render)
end
def test_cumulative_render_score_limit_across_render_tags
file_system = StubFileSystem.new(
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
)
environment = Liquid::Environment.build(file_system: file_system)
# Without cumulative limit, all 5 partials render successfully
t = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
unlimited_output = t.render!
total_cumulative = t.resource_limits.cumulative_render_score
# With cumulative limit set below the total, rendering stops early
t2 = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2
limited_output = t2.render
assert(t2.resource_limits.reached?)
assert_operator(limited_output.length, :<, unlimited_output.length)
end
def test_cumulative_render_score_limit_raises_on_render_bang
file_system = StubFileSystem.new(
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
)
environment = Liquid::Environment.build(file_system: file_system)
t = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
t.resource_limits.cumulative_render_score_limit = 20
assert_raises(Liquid::MemoryError) do
t.render!
end
end
def test_cumulative_assign_score_limit_across_include_tags
file_system = StubFileSystem.new(
'assign_partial' => '{% assign x = "a long string value here" %}',
)
environment = Liquid::Environment.build(file_system: file_system)
# Without cumulative limit, all 5 partials render
t = Template.parse(
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
environment: environment,
)
t.render!
total_cumulative = t.resource_limits.cumulative_assign_score
# With cumulative limit set below the total, rendering stops early
t2 = Template.parse(
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
environment: environment,
)
t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2
t2.render
assert(t2.resource_limits.reached?)
end
def test_cumulative_render_score_tracks_across_partials_without_limit
file_system = StubFileSystem.new(
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
)
environment = Liquid::Environment.build(file_system: file_system)
t = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
t.render!
assert(
t.resource_limits.cumulative_render_score > t.resource_limits.render_score,
"cumulative should exceed per-template score after multiple partials",
)
end
def test_default_resource_limits_unaffected_by_render_with_context
context = Context.new
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
@@ -339,7 +249,7 @@ class TemplateTest < Minitest::Test
end
def test_nil_value_does_not_raise
t = Template.parse("some{{x}}thing", error_mode: :strict)
t = Template.parse("some{{x}}thing")
result = t.render!({ 'x' => nil }, strict_variables: true)
assert_equal(0, t.errors.count)
+5 -74
View File
@@ -176,102 +176,33 @@ class VariableTest < Minitest::Test
)
end
def test_variable_lookup_should_not_hang_with_invalid_syntax
Timeout.timeout(1) do
assert_template_result(
'bar',
"{{['foo'}}",
{
'foo' => 'bar',
},
error_mode: :lax,
)
end
very_long_key = "1234567890" * 100
template_list = [
"{{['#{very_long_key}']}}", # valid
"{{['#{very_long_key}'}}", # missing closing bracket
"{{[['#{very_long_key}']}}", # extra open bracket
]
template_list.each do |template|
Timeout.timeout(1) do
assert_template_result(
'bar',
template,
{
very_long_key => 'bar',
},
error_mode: :lax,
)
end
end
end
def test_filter_with_single_trailing_comma
template = '{{ "hello" | append: "world", }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('helloworld', template)
end
assert_template_result('helloworld', template)
end
def test_multiple_filters_with_trailing_commas
template = '{{ "hello" | append: "1", | append: "2", }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('hello12', template)
end
assert_template_result('hello12', template)
end
def test_filter_with_colon_but_no_arguments
template = '{{ "test" | upcase: }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TEST', template)
end
assert_template_result('TEST', template)
end
def test_filter_chain_with_colon_no_args
template = '{{ "test" | append: "x" | upcase: }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
assert_template_result('TESTX', template)
end
def test_combining_trailing_comma_and_empty_args
template = '{{ "test" | append: "x", | upcase: }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
assert_template_result('TESTX', template)
end
end
+6 -23
View File
@@ -8,13 +8,6 @@ $LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib'))
require 'liquid.rb'
require 'liquid/profiler'
mode = :strict
if (env_mode = ENV['LIQUID_PARSER_MODE'])
puts "-- #{env_mode.upcase} ERROR MODE"
mode = env_mode.to_sym
end
Liquid::Environment.default.error_mode = mode
if Minitest.const_defined?('Test')
# We're on Minitest 5+. Nothing to do here.
else
@@ -34,27 +27,27 @@ module Minitest
def assert_template_result(
expected, template, assigns = {},
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
message: nil, partials: nil, render_errors: false,
template_factory: nil
)
file_system = StubFileSystem.new(partials || {})
environment = Liquid::Environment.build(file_system: file_system)
template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, environment: environment)
template = Liquid::Template.parse(template, line_numbers: true, environment: environment)
registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory)
context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, environment: environment)
output = template.render(context)
assert_equal(expected, output, message)
end
def assert_match_syntax_error(match, template, error_mode: nil)
def assert_match_syntax_error(match, template)
exception = assert_raises(Liquid::SyntaxError) do
Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym).render
Template.parse(template, line_numbers: true).render
end
assert_match(match, exception.message)
end
def assert_syntax_error(template, error_mode: nil)
assert_match_syntax_error("", template, error_mode: error_mode)
def assert_syntax_error(template)
assert_match_syntax_error("", template)
end
def assert_usage_increment(name, times: 1)
@@ -82,16 +75,6 @@ module Minitest
Environment.dangerously_override(environment, &blk)
end
def with_error_modes(*modes)
old_mode = Liquid::Environment.default.error_mode
modes.each do |mode|
Liquid::Environment.default.error_mode = mode
yield
end
ensure
Liquid::Environment.default.error_mode = old_mode
end
def with_custom_tag(tag_name, tag_class, &block)
environment = Liquid::Environment.default.dup
environment.register_tag(tag_name, tag_class)
+13 -23
View File
@@ -166,29 +166,8 @@ class ConditionUnitTest < Minitest::Test
assert_includes(err.lines.map(&:strip), expected)
end
def test_parse_expression_in_strict_mode
environment = Environment.build(error_mode: :strict)
parse_context = ParseContext.new(environment: environment)
result = Condition.parse_expression(parse_context, 'product.title')
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parse_expression_in_strict2_mode_raises_internal_error
environment = Environment.build(error_mode: :strict2)
parse_context = ParseContext.new(environment: environment)
error = assert_raises(Liquid::InternalError) do
Condition.parse_expression(parse_context, 'product.title')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_safe_true_in_strict2_mode
environment = Environment.build(error_mode: :strict2)
def test_parse_expression_with_safe_true
environment = Environment.build
parse_context = ParseContext.new(environment: environment)
result = Condition.parse_expression(parse_context, 'product.title', safe: true)
@@ -197,6 +176,17 @@ class ConditionUnitTest < Minitest::Test
assert_equal(['title'], result.lookups)
end
def test_parse_expression_raises_internal_error_if_not_safe
environment = Environment.build
parse_context = ParseContext.new(environment: environment)
error = assert_raises(Liquid::InternalError) do
Condition.parse_expression(parse_context, 'product.title')
end
assert_match(/unsafe parse_expression cannot be used/, error.message)
end
# Tests for blank? comparison without ActiveSupport
#
# Ruby's standard library does not include blank? on String, Array, Hash, etc.
-256
View File
@@ -1,256 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
# Tests for the byte-walking fast paths introduced in the ByteTables optimization.
# Each fast path is tested for equivalence with the original regex-based code path
# it replaces, covering accepted inputs, rejected inputs, and boundary cases.
class FastPathUnitTest < Minitest::Test
# ── Expression.parse_number ────────────────────────────────────────
# Fast path: byte-walk with ByteTables::DIGIT
# Replaces: INTEGER_REGEX, FLOAT_REGEX, StringScanner loop
def test_parse_number_simple_integers
assert_equal(42, Liquid::Expression.parse_number("42"))
assert_equal(0, Liquid::Expression.parse_number("0"))
assert_equal(999, Liquid::Expression.parse_number("999"))
end
def test_parse_number_negative_integers
assert_equal(-7, Liquid::Expression.parse_number("-7"))
assert_equal(-0, Liquid::Expression.parse_number("-0"))
assert_equal(-123, Liquid::Expression.parse_number("-123"))
end
def test_parse_number_simple_floats
assert_equal(3.14, Liquid::Expression.parse_number("3.14"))
assert_equal(0.5, Liquid::Expression.parse_number("0.5"))
assert_equal(-0.5, Liquid::Expression.parse_number("-0.5"))
assert_equal(100.0, Liquid::Expression.parse_number("100.0"))
end
def test_parse_number_trailing_dot
# "123." → 123.0 (truncate before dot)
assert_equal(123.0, Liquid::Expression.parse_number("123."))
assert_equal(0.0, Liquid::Expression.parse_number("0."))
end
def test_parse_number_multi_dot_floats
# "1.2.3" → 1.2 (truncate at second dot)
assert_equal(1.2, Liquid::Expression.parse_number("1.2.3"))
assert_equal(1.2, Liquid::Expression.parse_number("1.2.3.4"))
assert_equal(0.0, Liquid::Expression.parse_number("0.0.0"))
end
def test_parse_number_rejects_non_numeric
assert_nil(Liquid::Expression.parse_number("hello"))
assert_nil(Liquid::Expression.parse_number(""))
assert_nil(Liquid::Expression.parse_number("abc123"))
assert_nil(Liquid::Expression.parse_number(".5"))
assert_nil(Liquid::Expression.parse_number("-.5"))
end
def test_parse_number_rejects_trailing_alpha_after_multi_dot
# "1.2.3a" must be nil, not 1.2 — these are not valid numbers
assert_nil(Liquid::Expression.parse_number("1.2.3a"))
assert_nil(Liquid::Expression.parse_number("1.2.3.4a"))
assert_nil(Liquid::Expression.parse_number("1.2.34a"))
assert_nil(Liquid::Expression.parse_number("-1.2.3a"))
end
def test_parse_number_rejects_bare_dash
assert_nil(Liquid::Expression.parse_number("-"))
assert_nil(Liquid::Expression.parse_number("-a"))
end
# ── Expression.parse strip guard ───────────────────────────────────
# Fast path: skip String#strip when no leading/trailing whitespace
# Must produce identical results to unconditional .strip
def test_parse_strips_leading_whitespace
assert_equal(42, Liquid::Expression.parse(" 42"))
assert_equal(42, Liquid::Expression.parse("\t42"))
assert_equal(42, Liquid::Expression.parse("\n42"))
end
def test_parse_strips_trailing_whitespace
assert_equal(42, Liquid::Expression.parse("42 "))
assert_equal(42, Liquid::Expression.parse("42\t"))
assert_equal(42, Liquid::Expression.parse("42\n"))
end
def test_parse_strips_both_sides
assert_equal(42, Liquid::Expression.parse(" 42 "))
assert_equal("hello", Liquid::Expression.parse(" 'hello' "))
end
def test_parse_no_strip_needed
assert_equal(42, Liquid::Expression.parse("42"))
assert_equal("hello", Liquid::Expression.parse("'hello'"))
assert_equal(true, Liquid::Expression.parse("true"))
end
def test_parse_strips_null_bytes
# String#strip removes \x00 — the WHITESPACE table must match
assert_equal(true, Liquid::Expression.parse("\x00true"))
assert_equal(true, Liquid::Expression.parse("true\x00"))
end
# ── VariableLookup.simple_lookup? ──────────────────────────────────
# Fast path: regex gate for simple a.b.c chains
# Must accept only inputs the byte-walk can handle correctly
def test_simple_lookup_accepts_single_names
assert(Liquid::VariableLookup.simple_lookup?("product"))
assert(Liquid::VariableLookup.simple_lookup?("x"))
assert(Liquid::VariableLookup.simple_lookup?("_private"))
end
def test_simple_lookup_accepts_dotted_chains
assert(Liquid::VariableLookup.simple_lookup?("product.title"))
assert(Liquid::VariableLookup.simple_lookup?("a.b.c.d"))
end
def test_simple_lookup_accepts_question_marks
assert(Liquid::VariableLookup.simple_lookup?("product.available?"))
assert(Liquid::VariableLookup.simple_lookup?("empty?"))
end
def test_simple_lookup_accepts_hyphens
assert(Liquid::VariableLookup.simple_lookup?("my-var"))
assert(Liquid::VariableLookup.simple_lookup?("my-var.some-field"))
end
def test_simple_lookup_rejects_brackets
refute(Liquid::VariableLookup.simple_lookup?("product[0]"))
refute(Liquid::VariableLookup.simple_lookup?("hash['key']"))
refute(Liquid::VariableLookup.simple_lookup?("[0]"))
end
def test_simple_lookup_rejects_empty_and_malformed
refute(Liquid::VariableLookup.simple_lookup?(""))
refute(Liquid::VariableLookup.simple_lookup?(".leading"))
refute(Liquid::VariableLookup.simple_lookup?("trailing."))
refute(Liquid::VariableLookup.simple_lookup?("a..b"))
end
# ── VariableLookup fast path equivalence ───────────────────────────
# The fast path must produce identical name, lookups, and command_flags
# to the original VariableParser regex path
def test_fast_path_simple_name
vl = Liquid::VariableLookup.new("product")
assert_equal("product", vl.name)
assert_equal([], vl.lookups)
end
def test_fast_path_dotted_chain
vl = Liquid::VariableLookup.new("product.title")
assert_equal("product", vl.name)
assert_equal(["title"], vl.lookups)
end
def test_fast_path_deep_chain
vl = Liquid::VariableLookup.new("a.b.c.d")
assert_equal("a", vl.name)
assert_equal(["b", "c", "d"], vl.lookups)
end
def test_fast_path_command_methods
vl = Liquid::VariableLookup.new("items.size")
assert_equal("items", vl.name)
assert_equal(["size"], vl.lookups)
assert(vl.lookup_command?(0))
vl2 = Liquid::VariableLookup.new("items.first")
assert(vl2.lookup_command?(0))
vl3 = Liquid::VariableLookup.new("items.last")
assert(vl3.lookup_command?(0))
end
def test_fast_path_non_command_lookups
vl = Liquid::VariableLookup.new("product.title")
refute(vl.lookup_command?(0))
end
def test_fast_path_question_mark
vl = Liquid::VariableLookup.new("product.available?")
assert_equal("product", vl.name)
assert_equal(["available?"], vl.lookups)
end
def test_bracket_lookup_falls_to_regex_path
vl = Liquid::VariableLookup.new("product[0]")
assert_equal("product", vl.name)
assert_equal([0], vl.lookups)
end
# ── BlockBody.try_parse_tag_token ──────────────────────────────────
# Fast path: byte-walk tag tokens instead of FullToken regex
# Must produce identical [tag_name, markup, newline_count] or nil
def test_tag_token_simple
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% if x %}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_whitespace_control_leading
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{%- if x %}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_whitespace_control_trailing
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% if x -%}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_whitespace_control_both
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{%- if x -%}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_no_markup
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% endif %}")
assert_equal(["endif", "", 0], result)
end
def test_tag_token_hash_comment
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% # this is a comment %}")
assert_equal(["#", "this is a comment ", 0], result)
end
def test_tag_token_with_newlines
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% \n if \n x %}")
assert_equal(["if", "x ", 2], result)
end
def test_tag_token_hyphenated_name_stops_at_hyphen
# TagName = /\w+/ does not include hyphens
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% my-tag markup %}")
assert_equal("my", result[0])
end
def test_tag_token_complex_markup
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% for item in collection reversed %}")
assert_equal("for", result[0])
assert_equal("item in collection reversed ", result[1])
end
def test_tag_token_malformed_returns_nil
body = Liquid::BlockBody.new
# Token too short
assert_nil(body.send(:try_parse_tag_token, "{%"))
# No valid tag name start (digit)
assert_nil(body.send(:try_parse_tag_token, "{% 123 %}"))
end
end
+27 -64
View File
@@ -6,118 +6,81 @@ class ParseContextUnitTest < Minitest::Test
include Liquid
def test_safe_parse_expression_with_variable_lookup
parser_strict = strict_parse_context.new_parser('product.title')
result_strict = strict_parse_context.safe_parse_expression(parser_strict)
parser = parse_context.new_parser('product.title')
result = parse_context.safe_parse_expression(parser)
parser_strict2 = strict2_parse_context.new_parser('product.title')
result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2)
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
assert_instance_of(VariableLookup, result_strict2)
assert_equal('product', result_strict2.name)
assert_equal(['title'], result_strict2.lookups)
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
parser_strict = strict_parse_context.new_parser('')
parser_strict2 = strict2_parse_context.new_parser('')
parser = parse_context.new_parser('')
error_strict = assert_raises(Liquid::SyntaxError) do
strict_parse_context.safe_parse_expression(parser_strict)
end
assert_match(/is not a valid expression/, error_strict.message)
error_strict2 = assert_raises(Liquid::SyntaxError) do
strict2_parse_context.safe_parse_expression(parser_strict2)
error = assert_raises(Liquid::SyntaxError) do
parse_context.safe_parse_expression(parser)
end
assert_match(/is not a valid expression/, error_strict2.message)
assert_match(/is not a valid expression/, error.message)
end
def test_parse_expression_with_variable_lookup
result_strict = strict_parse_context.parse_expression('product.title')
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
error = assert_raises(Liquid::InternalError) do
strict2_parse_context.parse_expression('product.title')
parse_context.parse_expression('product.title')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
assert_match(/unsafe parse_expression cannot be used/, error.message)
end
def test_parse_expression_with_safe_true
result_strict = strict_parse_context.parse_expression('product.title', safe: true)
result = parse_context.parse_expression('product.title', safe: true)
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true)
assert_instance_of(VariableLookup, result_strict2)
assert_equal('product', result_strict2.name)
assert_equal(['title'], result_strict2.lookups)
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parse_expression_with_empty_string
result_strict = strict_parse_context.parse_expression('')
assert_nil(result_strict)
error = assert_raises(Liquid::InternalError) do
strict2_parse_context.parse_expression('')
parse_context.parse_expression('')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
assert_match(/unsafe parse_expression cannot be used/, error.message)
end
def test_parse_expression_with_empty_string_and_safe_true
result_strict = strict_parse_context.parse_expression('', safe: true)
assert_nil(result_strict)
result_strict2 = strict2_parse_context.parse_expression('', safe: true)
assert_nil(result_strict2)
result = parse_context.parse_expression('', safe: true)
assert_nil(result)
end
def test_safe_parse_expression_advances_parser_pointer
parser = strict2_parse_context.new_parser('foo, bar')
parser = parse_context.new_parser('foo, bar')
# safe_parse_expression consumes "foo"
first_result = strict2_parse_context.safe_parse_expression(parser)
first_result = parse_context.safe_parse_expression(parser)
assert_instance_of(VariableLookup, first_result)
assert_equal('foo', first_result.name)
parser.consume(:comma)
# safe_parse_expression consumes "bar"
second_result = strict2_parse_context.safe_parse_expression(parser)
second_result = parse_context.safe_parse_expression(parser)
assert_instance_of(VariableLookup, second_result)
assert_equal('bar', second_result.name)
parser.consume(:end_of_string)
end
def test_parse_expression_with_whitespace_in_strict2_mode
result = strict2_parse_context.parse_expression(' ', safe: true)
def test_parse_expression_with_whitespace
result = parse_context.parse_expression(' ', safe: true)
assert_nil(result)
end
private
def strict_parse_context
@strict_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :strict),
)
end
def strict2_parse_context
@strict2_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :strict2),
def parse_context
@parse_context ||= ParseContext.new(
environment: Environment.build,
)
end
end
+7 -9
View File
@@ -175,7 +175,7 @@ class PartialCacheUnitTest < Minitest::Test
assert_equal('some/path/my_partial', partial.name)
end
def test_includes_error_mode_into_template_cache
def test_cache_key
template_factory = StubTemplateFactory.new
context = Liquid::Context.build(
registers: {
@@ -184,16 +184,14 @@ class PartialCacheUnitTest < Minitest::Test
},
)
[:lax, :warn, :strict, :strict2].each do |error_mode|
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new(error_mode: error_mode),
)
end
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new,
)
assert_equal(
["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:strict2"],
["my_partial"],
context.registers[:cached_partials].keys,
)
end
-91
View File
@@ -1,91 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class ResourceLimitsUnitTest < Minitest::Test
def test_cumulative_scores_initialize_to_zero
limits = Liquid::ResourceLimits.new({})
assert_equal(0, limits.cumulative_render_score)
assert_equal(0, limits.cumulative_assign_score)
end
def test_cumulative_limits_default_to_nil
limits = Liquid::ResourceLimits.new({})
assert_nil(limits.cumulative_render_score_limit)
assert_nil(limits.cumulative_assign_score_limit)
end
def test_cumulative_limits_configurable_via_hash
limits = Liquid::ResourceLimits.new(
cumulative_render_score_limit: 500,
cumulative_assign_score_limit: 300,
)
assert_equal(500, limits.cumulative_render_score_limit)
assert_equal(300, limits.cumulative_assign_score_limit)
end
def test_cumulative_limits_configurable_via_accessor
limits = Liquid::ResourceLimits.new({})
limits.cumulative_render_score_limit = 500
assert_equal(500, limits.cumulative_render_score_limit)
end
def test_cumulative_scores_survive_reset
limits = Liquid::ResourceLimits.new({})
limits.increment_render_score(10)
limits.increment_assign_score(5)
limits.reset
assert_equal(0, limits.render_score)
assert_equal(0, limits.assign_score)
assert_equal(10, limits.cumulative_render_score)
assert_equal(5, limits.cumulative_assign_score)
end
def test_cumulative_scores_accumulate_across_resets
limits = Liquid::ResourceLimits.new({})
limits.increment_render_score(10)
limits.reset
limits.increment_render_score(20)
limits.reset
limits.increment_render_score(30)
assert_equal(30, limits.render_score)
assert_equal(60, limits.cumulative_render_score)
end
def test_cumulative_render_score_limit_raises
limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25)
limits.increment_render_score(10)
limits.reset
limits.increment_render_score(10)
limits.reset
assert_raises(Liquid::MemoryError) do
limits.increment_render_score(10)
end
assert(limits.reached?)
end
def test_cumulative_assign_score_limit_raises
limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15)
limits.increment_assign_score(8)
limits.reset
assert_raises(Liquid::MemoryError) do
limits.increment_assign_score(8)
end
assert(limits.reached?)
end
def test_per_template_limits_still_work_with_cumulative
limits = Liquid::ResourceLimits.new(
render_score_limit: 50,
cumulative_render_score_limit: 1000,
)
assert_raises(Liquid::MemoryError) do
limits.increment_render_score(51)
end
end
end
+16 -46
View File
@@ -20,15 +20,9 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict) do
assert_template_result("one", template)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
assert_match(/Expected end_of_string but found/, error.message)
end
def test_case_when_with_trailing_element
@@ -41,15 +35,9 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict) do
assert_template_result("one", template)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
assert_match(/Expected end_of_string but found/, error.message)
end
def test_case_when_with_comma
@@ -62,9 +50,7 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("one", template)
end
assert_template_result("one", template)
end
def test_case_when_with_or
@@ -77,9 +63,7 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("one", template)
end
assert_template_result("one", template)
end
def test_case_when_empty
@@ -92,14 +76,12 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("2 or empty", template, { 'x' => 2 })
assert_template_result("2 or empty", template, { 'x' => {} })
assert_template_result("2 or empty", template, { 'x' => [] })
assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } })
assert_template_result("not 2 or empty", template, { 'x' => ['a'] })
assert_template_result("not 2 or empty", template, { 'x' => 4 })
end
assert_template_result("2 or empty", template, { 'x' => 2 })
assert_template_result("2 or empty", template, { 'x' => {} })
assert_template_result("2 or empty", template, { 'x' => [] })
assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } })
assert_template_result("not 2 or empty", template, { 'x' => ['a'] })
assert_template_result("not 2 or empty", template, { 'x' => 4 })
end
def test_case_with_invalid_expression
@@ -113,15 +95,9 @@ class CaseTagUnitTest < Minitest::Test
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
with_error_modes(:lax, :strict) do
assert_template_result("one", template, assigns)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
assert_match(/Unexpected character =/, error.message)
end
def test_case_when_with_invalid_expression
@@ -135,14 +111,8 @@ class CaseTagUnitTest < Minitest::Test
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
with_error_modes(:lax, :strict) do
assert_template_result("one", template, assigns)
end
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
assert_match(/Unexpected character =/, error.message)
end
end
+26 -66
View File
@@ -72,12 +72,6 @@ class VariableUnitTest < Minitest::Test
assert_equal([['replace', ['foo', 'bar']], ['textileze', []]], var.filters)
end
def test_symbol
var = create_variable("http://disney.com/logo.gif | image: 'med' ", error_mode: :lax)
assert_equal(VariableLookup.new('http://disney.com/logo.gif'), var.name)
assert_equal([['image', ['med']]], var.filters)
end
def test_string_to_filter
var = create_variable("'http://disney.com/logo.gif' | image: 'med' ")
assert_equal('http://disney.com/logo.gif', var.name)
@@ -108,11 +102,9 @@ class VariableUnitTest < Minitest::Test
assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name)
assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name)
with_error_modes(:strict) do
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
end
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
end
def test_string_with_special_chars
@@ -131,70 +123,38 @@ class VariableUnitTest < Minitest::Test
assert_equal([['things', [], { 'greeting' => 'world', 'farewell' => 'goodbye' }]], var.filters)
end
def test_lax_filter_argument_parsing
var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax)
assert_equal(VariableLookup.new('number_of_comments'), var.name)
assert_equal([['pluralize', ['comment', 'comments']]], var.filters)
def test_filter_argument_parsing
# optional colon
var = create_variable(%(n | f1 | f2:))
assert_equal([['f1', []], ['f2', []]], var.filters)
# missing does not throws error
create_variable(%(n | f1: ,), error_mode: :lax)
create_variable(%(n | f1: ,| f2), error_mode: :lax)
# missing argument throws error
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
# arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':'
var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax)
assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters)
# arg requires colon
assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
# trailing comma doesn't throw
create_variable(%(n | f1: 1, 2, 3, | f2:))
# missing comma throws error
assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
# positional and kwargs parsing
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax)
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
# positional and kwargs mixed
var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
# positional and kwargs intermixed (pos1, key1: val1, pos2)
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax)
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
end
def test_strict_filter_argument_parsing
with_error_modes(:strict) do
assert_raises(SyntaxError) do
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
end
end
end
def test_strict2_filter_argument_parsing
with_error_modes(:strict2) do
# optional colon
var = create_variable(%(n | f1 | f2:))
assert_equal([['f1', []], ['f2', []]], var.filters)
# missing argument throws error
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
# arg requires colon
assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
# trailing comma doesn't throw
create_variable(%(n | f1: 1, 2, 3, | f2:))
# missing comma throws error
assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
# positional and kwargs parsing
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
# positional and kwargs mixed
var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
# positional and kwargs intermixed (pos1, key1: val1, pos2)
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
# string key throws
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
end
# string key throws
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
end
def test_output_raw_source_of_variable