From 532b4390634b111b0cae447347d7a6afd04b8d75 Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Wed, 18 Mar 2026 12:15:37 -0400 Subject: [PATCH] Reject bare-bracket syntax in strict2 and introduce `self` keyword Add bare-bracket rejection to Parser#expression in strict2 mode, so that `['var']` is disallowed and `self['var']` is the required syntax. - Add `Expression::SELF` constant ('self') - Add `Parser#reject_bare_brackets` option, checked in `expression` - Add `ParseContext#reject_bare_brackets?` and `force_reject_bare_brackets` - Add `VariableLookupDrop` for `self['var']` scope-chain lookups - Add `Variable#==` for rewriter state comparison - Update `Context#find_variable` to return `VariableLookupDrop` for `self` Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/liquid.rb | 1 + lib/liquid/context.rb | 13 +++++++++++ lib/liquid/expression.rb | 2 ++ lib/liquid/parse_context.rb | 2 +- lib/liquid/parser.rb | 6 ++++- lib/liquid/self_drop.rb | 38 +++++++++++++++++++++++++++++++ lib/liquid/variable.rb | 4 ++++ test/integration/context_test.rb | 4 ++-- test/integration/variable_test.rb | 10 ++++---- 9 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 lib/liquid/self_drop.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a64..dce089772 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -65,6 +65,7 @@ module Liquid require 'liquid/parser' require 'liquid/i18n' require 'liquid/drop' +require 'liquid/self_drop' require 'liquid/tablerowloop_drop' require 'liquid/forloop_drop' require 'liquid/extensions' diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 433b6d003..7902ebcff 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -187,6 +187,15 @@ def key?(key) find_variable(key, raise_on_not_found: false) != nil end + # Checks whether a variable is defined in any scope, including nil-valued keys. + # Unlike #key?, this uses Hash#key? so that variables explicitly set to nil + # are still considered defined. + def variable_defined?(key) + @scopes.any? { |s| s.key?(key) } || + @environments.any? { |e| e.key?(key) } || + @static_environments.any? { |e| e.key?(key) } + end + def evaluate(object) object.respond_to?(:evaluate) ? object.evaluate(self) : object end @@ -197,6 +206,10 @@ def find_variable(key, raise_on_not_found: true) # path and find_index() is optimized in MRI to reduce object allocation index = @scopes.find_index { |s| s.key?(key) } + # `self` resolves to a SelfDrop (enabling `self['var']` lookups), + # but only when it hasn't been explicitly assigned as a local variable. + return SelfDrop.new(self) if key == Expression::SELF && !index + variable = if index lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found) else diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 00c40a4c3..7e15f85b0 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -2,6 +2,8 @@ module Liquid class Expression + SELF = 'self' + LITERALS = { nil => nil, 'nil' => nil, diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 855acc64e..d956d1c61 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -38,7 +38,7 @@ def new_block_body def new_parser(input) @string_scanner.string = input - Parser.new(@string_scanner) + Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid) end def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 645dfa3a1..c2e2c5d28 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -2,10 +2,11 @@ module Liquid class Parser - def initialize(input) + def initialize(input, reject_bare_brackets: false) ss = input.is_a?(StringScanner) ? input : StringScanner.new(input) @tokens = Lexer.tokenize(ss) @p = 0 # pointer to current location + @reject_bare_brackets = reject_bare_brackets end def jump(point) @@ -53,6 +54,9 @@ def expression str = consume str << variable_lookups when :open_square + if @reject_bare_brackets + raise SyntaxError, "Bare bracket access is not allowed in strict2 mode. Use #{Expression::SELF}['...'] instead" + end str = consume.dup str << expression str << consume(:close_square) diff --git a/lib/liquid/self_drop.rb b/lib/liquid/self_drop.rb new file mode 100644 index 000000000..357814653 --- /dev/null +++ b/lib/liquid/self_drop.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Liquid + # @liquid_public_docs + # @liquid_type object + # @liquid_name self + # @liquid_summary + # Provides access to variables through the current scope chain. + # @liquid_description + # The `self` object resolves variables through the normal lookup hierarchy + # (local > file > global) without exposing filters, interrupts, errors, + # or other context internals. It's used when bare bracket notation + # (`['variable']`) needs to be replaced with an explicit variable lookup. + # + # If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`), + # then the local value takes precedence over the `self` object. + # @liquid_access global + class SelfDrop < Drop + def initialize(context) + super() + @context = context + end + + def [](key) + @context.find_variable(key) + rescue UndefinedVariable + nil + end + + def key?(key) + @context.variable_defined?(key) + end + + def to_liquid + self + end + end +end diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 6b5fb412b..12b8d9f28 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -37,6 +37,10 @@ def raw @markup end + def ==(other) + self.class == other.class && name == other.name && filters == other.filters + end + def markup_context(markup) "in \"{{#{markup}}}\"" end diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index d230734f6..8fa83ddb5 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -296,8 +296,8 @@ def test_access_hashes_with_hash_notation end def test_access_variable_with_hash_notation - assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" }) - assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' }) + assert_template_result('baz', '{{ foo }}', { "foo" => "baz" }) + assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' }) end def test_access_hashes_with_hash_access_variables diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index e272b96b9..58f73a315 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -55,7 +55,7 @@ def test_simple_with_whitespaces def test_expression_with_whitespace_in_square_brackets assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } }) - assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } }) + assert_template_result('result', "{{ a[ self[ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } }) end def test_ignore_unknown @@ -135,17 +135,17 @@ def test_nested_array end def test_dynamic_find_var - assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) + assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end def test_raw_value_variable - assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) + assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end def test_dynamic_find_var_with_drop assert_template_result( 'bar', - '{{ [list[settings.zero]] }}', + '{{ self[list[settings.zero]] }}', { 'list' => ['foo'], 'settings' => SettingsDrop.new("zero" => 0), @@ -155,7 +155,7 @@ def test_dynamic_find_var_with_drop assert_template_result( 'foo', - '{{ [list[settings.zero]["foo"]] }}', + '{{ self[list[settings.zero]["foo"]] }}', { 'list' => [{ 'foo' => 'bar' }], 'settings' => SettingsDrop.new("zero" => 0),