Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
beaea6f
Introduce :rigid parsing mode
karreiro Sep 30, 2025
69a807e
Add rigid_parse_with_error_context and clarifications
charlespwd Sep 30, 2025
06279ec
Add a rigid_parse method to `cycle`
charlespwd Sep 30, 2025
22232cc
Add rigid_parse to `render`
charlespwd Oct 1, 2025
40928e1
Add rigid_parse method to `include`
charlespwd Oct 1, 2025
8f5361b
Add `ExpressionParser` and `ExpressionConsumer`
karreiro Oct 1, 2025
7527f8a
Use `ExpressionParser` in the `ParseContext` when parsing in `:rigid`…
karreiro Oct 1, 2025
d9caf26
Remove `ExpressionParser` in favor of `ParseContext#safe_parse`
karreiro Oct 1, 2025
5625f6f
Fix cycle tag
karreiro Oct 2, 2025
42fcbcc
Fix an int the `cycle` tag, add extra unit tests, and updated parser …
karreiro Oct 2, 2025
0d7a7a5
Fail with trailing elements in the `cycle` tag
karreiro Oct 2, 2025
8e6faa4
Add rigid_parse to `case/when`
karreiro Oct 2, 2025
a16b955
Remove unnecessary skips
karreiro Oct 2, 2025
c36852d
Use safe_parse_expression instead of parse_expression
karreiro Oct 2, 2025
533396b
Make it possible to safe_parse subsets of expressions
charlespwd Oct 7, 2025
1308b97
Stricter 1:1 refactor of strict_parse for Variable
charlespwd Oct 8, 2025
5718f8b
rigid set_attribute in for parsing
charlespwd Oct 8, 2025
1ba6fab
No longer test `ParseContext` directly on `RigidModeUnitTest` as
karreiro Oct 8, 2025
fd81ac1
Remove redundant tests where rigid and strict modes have the same
karreiro Oct 8, 2025
376d849
Add rigid parser to `tablerow` tag
karreiro Oct 8, 2025
1c3c979
render end of string is not optional
charlespwd Oct 14, 2025
bc32531
Fix alias parsing
charlespwd Oct 16, 2025
26f092b
Fixup include parsing of with expression
charlespwd Oct 16, 2025
fd186dc
Fixup cycle rigid parsing to be backwards compatible
charlespwd Oct 20, 2025
937b733
Fix assert_template_result tests not picking up Liquid::Environment.d…
charlespwd Oct 20, 2025
82428de
* Update `bin/render` script to present an error when no template is …
karreiro Oct 22, 2025
e4bf43e
Update infrastructure that handles parsing switching:
karreiro Oct 22, 2025
f0ed8e5
Fix variable to keep it backward-compatible in strict mode
karreiro Oct 22, 2025
0946c4b
Covered changes with more tests, remove redundant cases, and the new …
karreiro Oct 22, 2025
44dfa39
Update `History.md`
karreiro Oct 22, 2025
195fd5a
Update `History.md` (5.8.8 -> 5.9.0)
karreiro Oct 22, 2025
54b41db
Update Rakefile
karreiro Oct 23, 2025
36ec055
Update test/unit/tags/case_tag_unit_test.rb
karreiro Oct 23, 2025
8c8a843
Update test/integration/tags/render_tag_test.rb
karreiro Oct 23, 2025
ba5fb99
Rename `with_error_mode(...)` to `with_error_modes(...)`
karreiro Oct 23, 2025
1e684d4
Add rigid mode to `rake benchmark` task
karreiro Oct 24, 2025
0064198
Simplify render/include tags following PR review feedback
karreiro Oct 24, 2025
d6c8892
Extract `/\w+:0x\h{8}/` regex to `UNNAMED_CYCLE_PATTERN` constant
karreiro Oct 24, 2025
0916f9a
Update README.md
karreiro Oct 27, 2025
438ac4f
Update test/integration/tags/table_row_test.rb
karreiro Oct 27, 2025
462a8b2
Add unit test mixing positional and kwargs arguments
karreiro Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Liquid Change Log

## 5.9.0
* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro]

## 5.8.7
* Expose body content in the `Doc` tag [James Meng]

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,14 @@ Setting the error mode of Liquid lets you specify how strictly you want your tem
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 a stricter parser that can be used when editing templates to give better error messages
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 = :strict
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
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.
Liquid::Environment.default.error_mode = :rigid # 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
Comment thread
karreiro marked this conversation as resolved.
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`:
Expand Down
19 changes: 16 additions & 3 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ task :rubocop do
end
end

desc('runs test suite with both strict and lax parsers')
desc('runs test suite with lax, strict, and rigid parsers')
task :test do
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['base_test'].invoke
Expand All @@ -42,6 +42,10 @@ task :test do
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke

ENV['LIQUID_PARSER_MODE'] = 'rigid'
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
Expand All @@ -50,6 +54,10 @@ task :test do
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke

ENV['LIQUID_PARSER_MODE'] = 'rigid'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end
end

Expand Down Expand Up @@ -80,8 +88,13 @@ namespace :benchmark do
ruby "./performance/benchmark.rb strict"
end

desc "Run the liquid benchmark with both lax and strict parsing"
task run: [:lax, :strict]
desc "Run the liquid benchmark with rigid parsing"
task :rigid do
ruby "./performance/benchmark.rb rigid"
end

desc "Run the liquid benchmark with lax, strict, and rigid parsing"
task run: [:lax, :strict, :rigid]

desc "Run unit benchmarks"
namespace :unit do
Expand Down
46 changes: 46 additions & 0 deletions bin/render
Comment thread
karreiro marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'bundler/setup'
require 'liquid'

class VirtualFileSystem
def initialize
snippet_1 = <<~LIQUID
<h1>
{{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!
</h1>
LIQUID
snippet_2 = <<~LIQUID
{%- for i in (1..5) -%}
> {{ i }}
{%- endfor -%}
LIQUID

@templates = {
'snippet-1' => snippet_1,
'snippet-2' => snippet_2,
}
end

def read_template_file(key)
@templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'")
end
end

def source
File.read(ARGV[0])
rescue StandardError
'Usage: bin/render example/server/templates/index.liquid'
end

def assigns
{
'date' => Time.now,
}
end

puts Liquid::Template
.parse(source, error_mode: :rigid)
.tap { |t| t.registers[:file_system] = VirtualFileSystem.new }
.render(assigns)
4 changes: 2 additions & 2 deletions lib/liquid/condition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ def self.operators
@@operators
end

def self.parse_expression(parse_context, markup)
@@method_literals[markup] || parse_context.parse_expression(markup)
def self.parse_expression(parse_context, markup, safe: false)
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
Comment thread
karreiro marked this conversation as resolved.
end

attr_reader :attachment, :child_condition
Expand Down
2 changes: 1 addition & 1 deletion lib/liquid/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class << self
# @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 :strict, :warn, or :lax).
# (either :rigid, :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.
Expand Down
4 changes: 4 additions & 0 deletions lib/liquid/expression.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class Expression
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/

class << self
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
parse(parser.expression, ss, cache)
end

def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup

Expand Down
1 change: 1 addition & 0 deletions lib/liquid/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
invalid_template_encoding: "Invalid template encoding"
render: "Syntax error in tag 'render' - Template name must be a quoted string"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
tag_never_closed: "'%{block_name}' tag was never closed"
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
unexpected_else: "%{block_name} tag does not expect 'else' tag"
Expand Down
17 changes: 16 additions & 1 deletion lib/liquid/parse_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,22 @@ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
)
end

def parse_expression(markup)
def safe_parse_expression(parser)
Expression.safe_parse(parser, @string_scanner, @expression_cache)
end

def parse_expression(markup, safe: false)
if !safe && @error_mode == :rigid
# parse_expression is a widely used API. To maintain backward
# compatibility while raising awareness about rigid parser standards,
# the safe flag supports API users make a deliberate decision.
#
# In rigid 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 rigid mode"
end

Expression.parse(markup, @string_scanner, @expression_cache)
end

Expand Down
27 changes: 26 additions & 1 deletion lib/liquid/parser_switching.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@

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 rigid mode where it uses the rigid parser.
#
# @deprecated Use {#parse_with_selected_parser} instead.
def strict_parse_with_error_mode_fallback(markup)
return rigid_parse_with_error_context(markup) if rigid_mode?

strict_parse_with_error_context(markup)
rescue SyntaxError => e
case parse_context.error_mode
when :rigid
raise
when :strict
raise
when :warn
Expand All @@ -16,20 +28,33 @@ def strict_parse_with_error_mode_fallback(markup)

def parse_with_selected_parser(markup)
case parse_context.error_mode
when :rigid then rigid_parse_with_error_context(markup)
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :warn
begin
strict_parse_with_error_context(markup)
rigid_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
end
end
end

def rigid_mode?
parse_context.error_mode == :rigid
end

private

def rigid_parse_with_error_context(markup)
rigid_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
Expand Down
8 changes: 6 additions & 2 deletions lib/liquid/tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ def blank?

private

def parse_expression(markup)
parse_context.parse_expression(markup)
def safe_parse_expression(parser)
parse_context.safe_parse_expression(parser)
end

def parse_expression(markup, safe: false)
parse_context.parse_expression(markup, safe: safe)
end
end
end
48 changes: 42 additions & 6 deletions lib/liquid/tags/case.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,7 @@ class Case < Block
def initialize(tag_name, markup, options)
super
@blocks = []

if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
parse_with_selected_parser(markup)
end

def parse(tokens)
Expand Down Expand Up @@ -91,9 +86,50 @@ def render_to_output_buffer(context, output)

private

def rigid_parse(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 rigid_mode?
parse_rigid_when(markup, body)
else
parse_lax_when(markup, body)
end
end

def parse_rigid_when(markup, body)
parser = @parse_context.new_parser(markup)

loop do
expr = safe_parse_expression(parser)
block = Condition.new(@left, '==', expr)
block.attach(body)
@blocks << block

break unless parser.id?('or') || parser.consume?(:comma)
end

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")
Expand Down
Loading