Skip to content

Commit 79888bf

Browse files
aswamyclaude
andcommitted
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) <noreply@anthropic.com>
1 parent dd37353 commit 79888bf

9 files changed

Lines changed: 71 additions & 9 deletions

File tree

lib/liquid.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ module Liquid
6565
require 'liquid/parser'
6666
require 'liquid/i18n'
6767
require 'liquid/drop'
68+
require 'liquid/self_drop'
6869
require 'liquid/tablerowloop_drop'
6970
require 'liquid/forloop_drop'
7071
require 'liquid/extensions'

lib/liquid/context.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,15 @@ def key?(key)
187187
find_variable(key, raise_on_not_found: false) != nil
188188
end
189189

190+
# Checks whether a variable is defined in any scope, including nil-valued keys.
191+
# Unlike #key?, this uses Hash#key? so that variables explicitly set to nil
192+
# are still considered defined.
193+
def variable_defined?(key)
194+
@scopes.any? { |s| s.key?(key) } ||
195+
@environments.any? { |e| e.key?(key) } ||
196+
@static_environments.any? { |e| e.key?(key) }
197+
end
198+
190199
def evaluate(object)
191200
object.respond_to?(:evaluate) ? object.evaluate(self) : object
192201
end
@@ -197,6 +206,10 @@ def find_variable(key, raise_on_not_found: true)
197206
# path and find_index() is optimized in MRI to reduce object allocation
198207
index = @scopes.find_index { |s| s.key?(key) }
199208

209+
# `self` resolves to a SelfDrop (enabling `self['var']` lookups),
210+
# but only when it hasn't been explicitly assigned as a local variable.
211+
return SelfDrop.new(self) if key == Expression::SELF && !index
212+
200213
variable = if index
201214
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
202215
else

lib/liquid/expression.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module Liquid
44
class Expression
5+
SELF = 'self'
6+
57
LITERALS = {
68
nil => nil,
79
'nil' => nil,

lib/liquid/parse_context.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def new_block_body
3838

3939
def new_parser(input)
4040
@string_scanner.string = input
41-
Parser.new(@string_scanner)
41+
Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid)
4242
end
4343

4444
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)

lib/liquid/parser.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
module Liquid
44
class Parser
5-
def initialize(input)
5+
def initialize(input, reject_bare_brackets: false)
66
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
77
@tokens = Lexer.tokenize(ss)
88
@p = 0 # pointer to current location
9+
@reject_bare_brackets = reject_bare_brackets
910
end
1011

1112
def jump(point)
@@ -53,6 +54,9 @@ def expression
5354
str = consume
5455
str << variable_lookups
5556
when :open_square
57+
if @reject_bare_brackets
58+
raise SyntaxError, "Bare bracket access is not allowed in strict2 mode. Use #{Expression::SELF}['...'] instead"
59+
end
5660
str = consume.dup
5761
str << expression
5862
str << consume(:close_square)

lib/liquid/self_drop.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
# @liquid_public_docs
5+
# @liquid_type object
6+
# @liquid_name self
7+
# @liquid_summary
8+
# Provides access to variables through the current scope chain.
9+
# @liquid_description
10+
# The `self` object resolves variables through the normal lookup hierarchy
11+
# (local > file > global) without exposing filters, interrupts, errors,
12+
# or other context internals. It's used when bare bracket notation
13+
# (`['variable']`) needs to be replaced with an explicit variable lookup.
14+
#
15+
# If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`),
16+
# then the local value takes precedence over the `self` object.
17+
# @liquid_access global
18+
class SelfDrop < Drop
19+
def initialize(context)
20+
super()
21+
@context = context
22+
end
23+
24+
def [](key)
25+
@context.find_variable(key)
26+
rescue UndefinedVariable
27+
nil
28+
end
29+
30+
def key?(key)
31+
@context.variable_defined?(key)
32+
end
33+
34+
def to_liquid
35+
self
36+
end
37+
end
38+
end

lib/liquid/variable.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def raw
3737
@markup
3838
end
3939

40+
def ==(other)
41+
self.class == other.class && name == other.name && filters == other.filters
42+
end
43+
4044
def markup_context(markup)
4145
"in \"{{#{markup}}}\""
4246
end

test/integration/context_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,8 @@ def test_access_hashes_with_hash_notation
296296
end
297297

298298
def test_access_variable_with_hash_notation
299-
assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" })
300-
assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
299+
assert_template_result('baz', '{{ foo }}', { "foo" => "baz" })
300+
assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
301301
end
302302

303303
def test_access_hashes_with_hash_access_variables

test/integration/variable_test.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_simple_with_whitespaces
5555

5656
def test_expression_with_whitespace_in_square_brackets
5757
assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } })
58-
assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
58+
assert_template_result('result', "{{ a[ self[ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
5959
end
6060

6161
def test_ignore_unknown
@@ -135,17 +135,17 @@ def test_nested_array
135135
end
136136

137137
def test_dynamic_find_var
138-
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
138+
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
139139
end
140140

141141
def test_raw_value_variable
142-
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
142+
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
143143
end
144144

145145
def test_dynamic_find_var_with_drop
146146
assert_template_result(
147147
'bar',
148-
'{{ [list[settings.zero]] }}',
148+
'{{ self[list[settings.zero]] }}',
149149
{
150150
'list' => ['foo'],
151151
'settings' => SettingsDrop.new("zero" => 0),
@@ -155,7 +155,7 @@ def test_dynamic_find_var_with_drop
155155

156156
assert_template_result(
157157
'foo',
158-
'{{ [list[settings.zero]["foo"]] }}',
158+
'{{ self[list[settings.zero]["foo"]] }}',
159159
{
160160
'list' => [{ 'foo' => 'bar' }],
161161
'settings' => SettingsDrop.new("zero" => 0),

0 commit comments

Comments
 (0)