Skip to content

Commit e597730

Browse files
committed
let tag init
1 parent d897899 commit e597730

21 files changed

+1943
-9
lines changed

.rubocop_todo.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Naming/ConstantName:
9191
- 'lib/liquid/tags/cycle.rb'
9292
- 'lib/liquid/tags/for.rb'
9393
- 'lib/liquid/tags/if.rb'
94+
- 'lib/liquid/tags/let.rb'
9495
- 'lib/liquid/tags/raw.rb'
9596
- 'lib/liquid/tags/table_row.rb'
9697
- 'lib/liquid/variable.rb'

.ruby-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.4.1
1+
3.3
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
**Re-Review: Liquid `let` tag -- Commit 56c35dfb**
2+
3+
## Precedent Gathered
4+
5+
### Reference Files
6+
| File | Why Selected | Key Patterns |
7+
|------|--------------|--------------|
8+
| `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/tags/assign.rb` | Closest sibling tag (variable assignment) | YARD `@liquid_public_docs` block, `Syntax` constant, `assign_score_of`, `ParseTreeVisitor` |
9+
| `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/tags/capture.rb` | Block-scoped variable tag | YARD docs, `Block#render` indirection, `has_let?` gating |
10+
| `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/tags/if.rb` | Block tag with `render_body` pattern | `render_body` helper, `has_let?` check, `context.stack` |
11+
| `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/tags/case.rb` | Block tag with `render_body` pattern | Identical `render_body` to `if.rb` |
12+
| `/Users/karreiro/src/github.com/Shopify/liquid/.rubocop_todo.yml` | RuboCop exclusions registry | Alphabetical ordering within `Naming/ConstantName` |
13+
| `/Users/karreiro/src/github.com/Shopify/liquid/test/unit/parse_tree_visitor_test.rb` | Existing `Style/SymbolProc` disable pattern | Line 269: `# rubocop:disable Style/SymbolProc` |
14+
15+
---
16+
17+
## Verification of Claimed Fixes
18+
19+
### Finding #1: YARD Documentation on `let.rb`
20+
21+
**Precedent:** `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/tags/assign.rb:4-19` uses the `@liquid_public_docs` block with `@liquid_type`, `@liquid_category`, `@liquid_name`, `@liquid_summary`, `@liquid_description`, `@liquid_syntax`, and `@liquid_syntax_keyword` annotations, placed immediately before the class declaration.
22+
23+
**New code:** `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/tags/let.rb:4-17` now has a `@liquid_public_docs` block with the same annotation structure: `@liquid_type tag`, `@liquid_category variable`, `@liquid_name let`, `@liquid_summary`, `@liquid_description`, `@liquid_syntax`, and `@liquid_syntax_keyword` entries.
24+
25+
**Verdict:** Consistent. The annotation ordering matches `assign.rb` and `capture.rb`. Category is `variable`, matching the other variable-family tags (`assign`, `capture`).
26+
27+
---
28+
29+
### Finding #2: `.rubocop_todo.yml` Exclusion for `let.rb`
30+
31+
**Precedent:** `/Users/karreiro/src/github.com/Shopify/liquid/.rubocop_todo.yml:84-100` lists `Naming/ConstantName` exclusions in alphabetical order: `assign.rb`, `capture.rb`, `case.rb`, `cycle.rb`, `for.rb`, `if.rb`, ... `raw.rb`, `table_row.rb`.
32+
33+
**New code:** `/Users/karreiro/src/github.com/Shopify/liquid/.rubocop_todo.yml:94` inserts `lib/liquid/tags/let.rb` between `if.rb` (line 93) and `raw.rb` (line 95).
34+
35+
**Verdict:** Consistent. Alphabetical ordering is preserved.
36+
37+
---
38+
39+
### Finding #6: `capture.rb` Else Branch -- `render(context)` Indirection
40+
41+
**Precedent:** `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/block.rb:20-22` defines `Block#render(context)` as a backwards-compatibility wrapper that delegates to `@body.render(context)`. The original `capture.rb` used `render(context)` in its else branch prior to the `let` feature, preserving this indirection layer.
42+
43+
**New code:** `/Users/karreiro/src/github.com/Shopify/liquid/lib/liquid/tags/capture.rb:39` uses `render(context)` in the else branch (when `has_let?` is false), while the `has_let?` branch on line 37 uses `context.stack { @body.render(context) }`.
44+
45+
**Verdict:** Consistent. The else branch preserves the `Block#render` indirection for the non-`let` path, matching the original behavior. The `has_let?` branch correctly adds `context.stack` wrapping.
46+
47+
**Note:** The `if.rb` and `case.rb` tags use a different pattern for their else branches -- they call `body.render_to_output_buffer(context, output)` directly. This difference is expected because `capture.rb` needs to capture output as a string (via `@body.render(context)` which returns a string), while `if.rb`/`case.rb` write directly to an output buffer. The patterns are structurally different for a valid reason.
48+
49+
---
50+
51+
### RuboCop Fix: `Style/RegexpLiteral` in `let_tag_test.rb`
52+
53+
**New code:** `/Users/karreiro/src/github.com/Shopify/liquid/test/integration/tags/let_tag_test.rb:125` uses `%r{</tr>\d}` instead of `/<\/tr>\d/`.
54+
55+
**Verdict:** Consistent with RuboCop's `Style/RegexpLiteral` rule. The `%r{}` form is the correct way to avoid escaping forward slashes.
56+
57+
---
58+
59+
### RuboCop Fix: `Style/SymbolProc` Disable in `let_tag_unit_test.rb`
60+
61+
**Precedent:** `/Users/karreiro/src/github.com/Shopify/liquid/test/unit/parse_tree_visitor_test.rb:269` uses the identical pattern:
62+
```ruby
63+
.add_callback_for(VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
64+
```
65+
66+
**New code:** `/Users/karreiro/src/github.com/Shopify/liquid/test/unit/tags/let_tag_unit_test.rb:61` uses:
67+
```ruby
68+
.add_callback_for(VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
69+
```
70+
71+
**Verdict:** Identical to precedent. The disable comment is justified: `add_callback_for` invokes the block with `(node, context)`, so `&:name` would pass both arguments to `Symbol#to_proc`, which only expects `self`. The block form is necessary.
72+
73+
---
74+
75+
## Non-Blocking Items (Confirmed Acknowledged)
76+
77+
| Finding | Reason Not Changed | Assessment |
78+
|---------|--------------------|------------|
79+
| #3: `assign_score_of` duplication | No extraction pattern exists in codebase | Correct -- duplication matches `assign.rb` exactly |
80+
| #4: `has_let?` naming | `Naming/PredicatePrefix` disabled in `.rubocop_todo.yml:183-184` | Correct -- not enforceable |
81+
| #5: `render_body` duplication in `if.rb`/`case.rb` | No extraction pattern exists | Correct -- both tags define identical private methods |
82+
| #7: Category separator comments in tests | Cosmetic; no enforced convention | Correct -- harmless |
83+
84+
---
85+
86+
## Findings
87+
88+
| Deviation | Status |
89+
|-----------|--------|
90+
| Finding #1 (YARD docs) | **Resolved** -- matches `assign.rb`/`capture.rb` pattern |
91+
| Finding #2 (`.rubocop_todo.yml`) | **Resolved** -- alphabetically correct |
92+
| Finding #6 (`render(context)` indirection) | **Resolved** -- preserves `Block#render` wrapper |
93+
| RuboCop `Style/RegexpLiteral` | **Resolved** -- `%r{}` form correct |
94+
| RuboCop `Style/SymbolProc` | **Resolved** -- matches `parse_tree_visitor_test.rb:269` |
95+
96+
**Total remaining deviations from established patterns:** 0
97+
98+
---
99+
100+
## Ambiguities
101+
102+
None -- patterns are consistent.
103+
104+
---
105+
106+
## Verdict
107+
108+
**APPROVED.**
109+
110+
All blocking deviations from the original review have been addressed. Each fix is verified against existing codebase precedent. The non-blocking items were correctly identified as acceptable and left unchanged. Test suite passes with 0 failures and RuboCop reports 0 offenses.

bin/try

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require 'bundler/setup'
5+
require 'liquid'
6+
7+
assigns = {}
8+
9+
puts Liquid::Template.parse('{{ 1 | plus: 2 }}').render(assigns)

lib/liquid.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ module Liquid
8282
require 'liquid/expression'
8383
require 'liquid/template'
8484
require 'liquid/condition'
85+
require 'liquid/object_literal'
86+
require 'liquid/array_literal'
8587
require 'liquid/utils'
8688
require 'liquid/tokenizer'
8789
require 'liquid/parse_context'

lib/liquid/array_literal.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
class ArrayLiteral
5+
attr_reader :entries
6+
7+
def initialize(entries)
8+
@entries = entries
9+
end
10+
11+
def evaluate(context)
12+
result = []
13+
14+
@entries.each do |entry|
15+
case entry[0]
16+
when :element
17+
value_expr = entry[1]
18+
result << context.evaluate(value_expr)
19+
when :spread
20+
expr = entry[1]
21+
value = context.evaluate(expr)
22+
23+
if value.is_a?(Array)
24+
result.concat(value)
25+
elsif value.respond_to?(:to_spread)
26+
spread_result = value.to_spread
27+
unless spread_result.is_a?(Array)
28+
raise Liquid::ArgumentError, "to_spread must return an Array for array spread, got #{spread_result.class}"
29+
end
30+
31+
result.concat(spread_result)
32+
else
33+
raise Liquid::ArgumentError, "Cannot spread a non-array value into an array literal (#{value.class})"
34+
end
35+
end
36+
end
37+
38+
result
39+
end
40+
41+
class ParseTreeVisitor < Liquid::ParseTreeVisitor
42+
def children
43+
@node.entries.map { |entry| entry[1] }
44+
end
45+
end
46+
end
47+
end

lib/liquid/block_body.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class BlockBody
1717
def initialize
1818
@nodelist = []
1919
@blank = true
20+
@has_let = false
2021
end
2122

2223
def parse(tokenizer, parse_context, &block)
@@ -36,6 +37,10 @@ def freeze
3637
super
3738
end
3839

40+
def has_let?
41+
@has_let
42+
end
43+
3944
private def parse_for_liquid_tag(tokenizer, parse_context)
4045
while (token = tokenizer.shift)
4146
unless token.empty? || token.match?(WhitespaceOrNothing)
@@ -60,6 +65,7 @@ def freeze
6065
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
6166
@blank &&= new_tag.blank?
6267
@nodelist << new_tag
68+
@has_let = true if new_tag.is_a?(Let)
6369
end
6470
parse_context.line_number = tokenizer.line_number
6571
end
@@ -155,6 +161,7 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
155161
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
156162
@blank &&= new_tag.blank?
157163
@nodelist << new_tag
164+
@has_let = true if new_tag.is_a?(Let)
158165
when token.start_with?(VARSTART)
159166
whitespace_handler(token, parse_context)
160167
@nodelist << create_variable(token, parse_context)

lib/liquid/lexer.rb

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

33
module Liquid
44
class Lexer
5+
CLOSE_CURLY = [:close_curly, "}"].freeze
56
CLOSE_ROUND = [:close_round, ")"].freeze
67
CLOSE_SQUARE = [:close_square, "]"].freeze
78
COLON = [:colon, ":"].freeze
@@ -22,12 +23,14 @@ class Lexer
2223
EOS = [:end_of_string].freeze
2324
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
2425
NUMBER_LITERAL = /-?\d+(\.\d+)?/
26+
OPEN_CURLY = [:open_curly, "{"].freeze
2527
OPEN_ROUND = [:open_round, "("].freeze
2628
OPEN_SQUARE = [:open_square, "["].freeze
2729
PIPE = [:pipe, "|"].freeze
2830
QUESTION = [:question, "?"].freeze
2931
RUBY_WHITESPACE = [" ", "\t", "\r", "\n", "\f"].freeze
3032
SINGLE_STRING_LITERAL = /'[^\']*'/
33+
SPREAD = [:spread, "..."].freeze
3134
WHITESPACE_OR_NOTHING = /\s*/
3235

3336
SINGLE_COMPARISON_TOKENS = [].tap do |table|
@@ -89,6 +92,8 @@ class Lexer
8992
table[")".ord] = CLOSE_ROUND
9093
table["?".ord] = QUESTION
9194
table["-".ord] = DASH
95+
table["{".ord] = OPEN_CURLY
96+
table["}".ord] = CLOSE_CURLY
9297
end
9398

9499
NUMBER_TABLE = [].tap do |table|
@@ -113,10 +118,15 @@ def tokenize(ss)
113118

114119
if (special = SPECIAL_TABLE[peeked])
115120
ss.scan_byte
116-
# Special case for ".."
121+
# Special case for ".." and "..."
117122
if special == DOT && ss.peek_byte == DOT_ORD
118123
ss.scan_byte
119-
output << DOTDOT
124+
if ss.peek_byte == DOT_ORD
125+
ss.scan_byte
126+
output << SPREAD
127+
else
128+
output << DOTDOT
129+
end
120130
elsif special == DASH
121131
# Special case for negative numbers
122132
if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte]

lib/liquid/locales/en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}"
55
block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}"
66
assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"
7+
let: "Syntax Error in 'let' - Valid syntax: let [var] = [expression]"
8+
let_object: "Syntax Error in 'let' - Invalid object literal syntax"
9+
let_array: "Syntax Error in 'let' - Invalid array literal syntax"
710
capture: "Syntax Error in 'capture' - Valid syntax: capture [var]"
811
case: "Syntax Error in 'case' - Valid syntax: case [condition]"
912
case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}"

lib/liquid/object_literal.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
class ObjectLiteral
5+
attr_reader :entries
6+
7+
def initialize(entries)
8+
@entries = entries
9+
end
10+
11+
def evaluate(context)
12+
result = {}
13+
14+
@entries.each do |entry|
15+
case entry[0]
16+
when :pair
17+
key = entry[1]
18+
value_expr = entry[2]
19+
result[key] = context.evaluate(value_expr)
20+
when :spread
21+
expr = entry[1]
22+
value = context.evaluate(expr)
23+
24+
if value.is_a?(Hash)
25+
result.merge!(value)
26+
elsif value.respond_to?(:to_spread)
27+
spread_result = value.to_spread
28+
unless spread_result.is_a?(Hash)
29+
raise Liquid::ArgumentError, "to_spread must return a Hash for object spread, got #{spread_result.class}"
30+
end
31+
result.merge!(spread_result)
32+
else
33+
raise Liquid::ArgumentError, "Cannot spread a non-hash value into an object literal (#{value.class})"
34+
end
35+
end
36+
end
37+
38+
result
39+
end
40+
41+
class ParseTreeVisitor < Liquid::ParseTreeVisitor
42+
def children
43+
@node.entries.map { |entry| entry[0] == :pair ? entry[2] : entry[1] }
44+
end
45+
end
46+
end
47+
end

0 commit comments

Comments
 (0)