diff --git a/README.md b/README.md
index f62285b9..a6d63363 100644
--- a/README.md
+++ b/README.md
@@ -325,8 +325,8 @@ out of regular runs.
| Suite | Tests | Description |
|-------|-------|-------------|
-| **basics** | 945 | Essential Liquid features - start here! Ordered by complexity with implementation hints |
-| **liquid_ruby** | 2,097 | Core Liquid specs from [Shopify/liquid](https://github.com/Shopify/liquid) integration tests |
+| **basics** | 955 | Essential Liquid features - start here! Ordered by complexity with implementation hints |
+| **liquid_ruby** | 2,106 | Core Liquid specs from [Shopify/liquid](https://github.com/Shopify/liquid) integration tests |
| **liquid_ruby_lax** | 121 | Lax-mode reference behavior |
| **parser_errors** | 1,905 | Strict parser error compatibility and mutation matrices |
| **partials** | 12 | Include/render focused compatibility specs and timings |
diff --git a/examples/json_rpc_ruby_liquid.rb b/examples/json_rpc_ruby_liquid.rb
index a1693860..930632e4 100644
--- a/examples/json_rpc_ruby_liquid.rb
+++ b/examples/json_rpc_ruby_liquid.rb
@@ -47,7 +47,7 @@
config.render_error_modes = [:raise, :inline]
# This JSON-RPC adapter supports core behavior including drops.
# because they implement bidirectional callbacks
- config.missing_features = [:ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :strict2_blank_body_errors]
+ config.missing_features = [:ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits, :strict2_blank_body_errors]
end
LiquidSpec.compile do |ctx, source, parse_options|
diff --git a/examples/liquid_c.rb b/examples/liquid_c.rb
index 724ba8a6..c14867b7 100644
--- a/examples/liquid_c.rb
+++ b/examples/liquid_c.rb
@@ -38,7 +38,7 @@ def parse_expression(markup, safe: false)
LiquidSpec.configure do |config|
config.error_modes = [:lax]
config.render_error_modes = [:raise, :inline]
- config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
+ config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits]
end
LiquidSpec.compile do |ctx, source, parse_options|
diff --git a/examples/liquid_c_strict.rb b/examples/liquid_c_strict.rb
index 45440872..ebf0e097 100644
--- a/examples/liquid_c_strict.rb
+++ b/examples/liquid_c_strict.rb
@@ -26,7 +26,7 @@
LiquidSpec.configure do |config|
config.error_modes = [:strict]
config.render_error_modes = [:raise, :inline]
- config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
+ config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits]
end
LiquidSpec.compile do |ctx, source, parse_options|
diff --git a/examples/liquid_ruby.rb b/examples/liquid_ruby.rb
index 5fd2210f..4d3021d8 100644
--- a/examples/liquid_ruby.rb
+++ b/examples/liquid_ruby.rb
@@ -22,7 +22,7 @@
LiquidSpec.configure do |config|
config.error_modes = [:strict2, :strict]
config.render_error_modes = [:raise, :inline]
- config.missing_features = [:self_environment_shadowing, :drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :strict2_blank_body_errors]
+ config.missing_features = [:self_environment_shadowing, :drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits, :strict2_blank_body_errors]
end
LiquidSpec.compile do |ctx, source, parse_options|
diff --git a/examples/liquid_ruby_lax.rb b/examples/liquid_ruby_lax.rb
index 5cc54806..28ee15b3 100644
--- a/examples/liquid_ruby_lax.rb
+++ b/examples/liquid_ruby_lax.rb
@@ -22,7 +22,7 @@
LiquidSpec.configure do |config|
config.error_modes = [:lax]
config.render_error_modes = [:raise, :inline]
- config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
+ config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits]
end
LiquidSpec.compile do |ctx, source, parse_options|
diff --git a/examples/liquid_ruby_shopify.rb b/examples/liquid_ruby_shopify.rb
index 89bd9608..92ef0919 100644
--- a/examples/liquid_ruby_shopify.rb
+++ b/examples/liquid_ruby_shopify.rb
@@ -188,7 +188,7 @@ def render_to_output_buffer(context, output)
config.render_error_modes = [:raise, :inline]
# This adapter is a benchmark fixture with a few local theme filters, not a
# full Shopify runtime. Keep production/theme suites out of its matrix row.
- config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits]
+ config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :range_resource_limits]
config.suites = [:benchmarks]
config.filter = "shopify_"
end
diff --git a/examples/liquid_ruby_yjit.rb b/examples/liquid_ruby_yjit.rb
index 27bae86f..723b0812 100644
--- a/examples/liquid_ruby_yjit.rb
+++ b/examples/liquid_ruby_yjit.rb
@@ -24,7 +24,7 @@
LiquidSpec.configure do |config|
config.error_modes = [:strict2, :strict]
config.render_error_modes = [:raise, :inline]
- config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :strict2_blank_body_errors]
+ config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :range_resource_limits, :strict2_blank_body_errors]
end
LiquidSpec.compile do |ctx, source, parse_options|
diff --git a/examples/liquid_ruby_zjit.rb b/examples/liquid_ruby_zjit.rb
index bd5452b1..d28d9052 100644
--- a/examples/liquid_ruby_zjit.rb
+++ b/examples/liquid_ruby_zjit.rb
@@ -24,7 +24,7 @@
LiquidSpec.configure do |config|
config.error_modes = [:strict2, :strict]
config.render_error_modes = [:raise, :inline]
- config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :strict2_blank_body_errors]
+ config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :range_resource_limits, :strict2_blank_body_errors]
end
LiquidSpec.compile do |ctx, source, parse_options|
diff --git a/lib/liquid/spec/cli/adapter_dsl.rb b/lib/liquid/spec/cli/adapter_dsl.rb
index fd9540a2..7cdc7020 100644
--- a/lib/liquid/spec/cli/adapter_dsl.rb
+++ b/lib/liquid/spec/cli/adapter_dsl.rb
@@ -80,6 +80,7 @@ def format_timeout(value)
strict2_blank_body_errors: "strict2 surfaces inline errors even when the block body is blank",
drops: "Standard test drop library (BooleanDrop, MethodDrop, SequenceDrop, etc.)",
shopify_resource_limits: "Shopify resource limit enforcement (render score tracking, cumulative limits across partials)",
+ range_resource_limits: "Post-5.13 range iteration resource-limit accounting",
randomness: "Specs using generated random values for increased coverage",
}.freeze
class Configuration
diff --git a/lib/liquid/spec/cli/features.rb b/lib/liquid/spec/cli/features.rb
index 711451c8..78fc7480 100644
--- a/lib/liquid/spec/cli/features.rb
+++ b/lib/liquid/spec/cli/features.rb
@@ -129,6 +129,11 @@ module Features
recommendation: :optional,
note: "Score bookkeeping and limit enforcement is implementation plumbing, not Liquid semantics. Recursion depth / stack overflow specs are core; score-counting specs are opt-out.",
},
+ range_resource_limits: {
+ description: "Post-5.13 range iteration resource-limit accounting",
+ recommendation: :optional,
+ note: "Requires the range iteration accounting introduced after Liquid 5.13. Keep it disabled until that behavior is implemented.",
+ },
}.freeze
RECOMMENDATION_LABELS = {
diff --git a/lib/liquid/spec/cli/init.rb b/lib/liquid/spec/cli/init.rb
index 950c07c8..73f61dc2 100644
--- a/lib/liquid/spec/cli/init.rb
+++ b/lib/liquid/spec/cli/init.rb
@@ -361,6 +361,7 @@ module Init
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
+ :range_resource_limits,
:strict2_blank_body_errors,
]
end
diff --git a/lib/liquid/spec/deps/liquid_ruby.rb b/lib/liquid/spec/deps/liquid_ruby.rb
index 79c49691..d0f73a4e 100644
--- a/lib/liquid/spec/deps/liquid_ruby.rb
+++ b/lib/liquid/spec/deps/liquid_ruby.rb
@@ -269,9 +269,9 @@ def to_liquid
class RangeWrapper
def self.new(params)
if params.is_a?(Array)
- Range.new(params[0], params[1])
+ Range.new(params[0], params[1], params[2] || false)
elsif params.is_a?(Hash)
- Range.new(params["begin"] || params[:begin], params["end"] || params[:end])
+ Range.new(params["begin"] || params[:begin], params["end"] || params[:end], params["exclude_end"] || params[:exclude_end] || false)
else
params
end
@@ -551,8 +551,9 @@ def secret; "FAKE_SECRET"; end
Liquid::Spec::ClassRegistry.register("FakeDropObject") { |p| FakeDropObject.new }
-# Range - special handling for array format [start, end]
-Liquid::Spec::ClassRegistry.register("Range") { |p| Range.new(p[0], p[1]) }
+# Range - array format [start, end] retains the historic inclusive encoding;
+# a third truthy item preserves an exclusive endpoint.
+Liquid::Spec::ClassRegistry.register("Range") { |p| Range.new(p[0], p[1], p[2] || false) }
# Returns the Liquid::Drop class itself (for edge case tests)
Liquid::Spec::ClassRegistry.register("LiquidDropClass") { |_p| Liquid::Drop }
diff --git a/lib/liquid/spec/deps/yaml_coders.rb b/lib/liquid/spec/deps/yaml_coders.rb
index cc37633b..c14a09d1 100644
--- a/lib/liquid/spec/deps/yaml_coders.rb
+++ b/lib/liquid/spec/deps/yaml_coders.rb
@@ -13,10 +13,14 @@
# --- Core type coders ---
-# Range serializes as instantiate:Range: [begin, end]
+# Inclusive ranges retain the historical [begin, end] representation. Exclusive
+# ranges append true so their endpoint semantics survive capture; nil end supports
+# endless ranges without a special representation.
class Range
def encode_with(coder)
- coder.represent_map(nil, { "instantiate:Range:" => [self.begin, self.end] })
+ params = [self.begin, self.end]
+ params << true if exclude_end?
+ coder.represent_map(nil, { "instantiate:Range:" => params })
end
end
diff --git a/scripts/verifiers/spec_schema.rb b/scripts/verifiers/spec_schema.rb
index 6985b118..7a66f5fa 100755
--- a/scripts/verifiers/spec_schema.rb
+++ b/scripts/verifiers/spec_schema.rb
@@ -51,6 +51,7 @@
drops
randomness
shopify_resource_limits
+ range_resource_limits
activesupport
].freeze
diff --git a/specs/basics/range-resource-limits.yml b/specs/basics/range-resource-limits.yml
new file mode 100644
index 00000000..3e5ef954
--- /dev/null
+++ b/specs/basics/range-resource-limits.yml
@@ -0,0 +1,146 @@
+_metadata:
+ hint: |
+ Regression coverage for bounded range iteration's resource-limit accounting.
+ These cases require the post-5.13 range resource-limit implementation in
+ addition to Shopify resource-limit support.
+ complexity: 500
+ features:
+ - shopify_resource_limits
+ - range_resource_limits
+
+specs:
+- name: for_dynamic_range_empty_body_charges_each_item
+ complexity: 500
+ template: "{% for i in (first..last) %}{% endfor %}"
+ environment:
+ first: 1
+ last: 3
+ error_mode: [strict2, strict]
+ render_errors: false
+ resource_limits:
+ render_score_limit: 3
+ errors:
+ render_error:
+ - Memory limits exceeded
+ hint: |
+ The root loop tag costs one render-score unit and its three yielded values
+ cost three more, even when the body is empty. Dynamic bounds must use the
+ same accounting as a literal range, so the four-unit loop exceeds 3.
+
+- name: tablerow_literal_range_empty_body_charges_each_item
+ complexity: 501
+ template: "{% tablerow i in (1..3) %}{% endtablerow %}"
+ error_mode: [strict2, strict]
+ render_errors: false
+ resource_limits:
+ render_score_limit: 3
+ errors:
+ render_error:
+ - Memory limits exceeded
+ hint: |
+ tablerow also charges its tag plus every range item before rendering the
+ body. Empty cells still consume work, so the fourth unit exceeds this
+ render-score limit.
+
+- name: for_literal_range_reversed_offset_limit_keeps_metadata
+ complexity: 502
+ template: "{% for i in (1..10) reversed offset:7 limit:2 %}{{ forloop.length }}:{{ forloop.index }}:{{ i }}{% endfor %}"
+ expected: "2:1:92:2:8"
+ hint: |
+ Apply offset and limit to the range segment without materializing it, then
+ reverse that segment. forloop.length and forloop.index describe the two
+ selected values (9 and 8), not the original ten-item range.
+
+- name: tablerow_literal_range_offset_limit_keeps_metadata
+ complexity: 503
+ template: "{% tablerow i in (1..10) offset:7 limit:2 %}{{ tablerowloop.length }}:{{ tablerowloop.index }}:{{ i }}{% endtablerow %}"
+ expected: |
+
+ | 2:1:8 | 2:2:9 |
+ hint: |
+ tablerow selects a bounded range segment before generating markup. Its
+ loop metadata reports the two selected values (8 and 9), including their
+ one-based indexes, rather than the full range length.
+
+- name: range_score_is_cumulative_across_isolated_render_partials
+ complexity: 504
+ template: "{% render 'range_loop' %}{% render 'range_loop' %}"
+ error_mode: [strict2, strict]
+ render_errors: false
+ filesystem:
+ range_loop.liquid: "{% for i in (1..2) %}{% endfor %}"
+ resource_limits:
+ cumulative_render_score_limit: 5
+ errors:
+ render_error:
+ - Memory limits exceeded
+ hint: |
+ render creates an isolated Liquid context, but its work contributes to the
+ caller's cumulative render score. Two empty two-item range loops cost more
+ than 5 together even though neither partial has its own per-render limit.
+
+- name: tablerow_capture_output_respects_assign_score_limit
+ complexity: 505
+ template: "{% capture table %}{% tablerow i in (1..2) %}{{ i }}{% endtablerow %}{% endcapture %}"
+ error_mode: [strict2, strict]
+ render_errors: false
+ resource_limits:
+ assign_score_limit: 69
+ errors:
+ render_error:
+ - Memory limits exceeded
+ hint: |
+ Capturing tablerow's generated HTML adds all 70 bytes to assign score even
+ though capture emits no final output. The limit is checked against the
+ complete generated table, not just the tag body.
+
+- name: tablerow_break_final_markup_respects_render_length_limit
+ complexity: 506
+ template: "{% tablerow i in (1..3) %}{{ i }}{% break %}{% endtablerow %}"
+ error_mode: [strict2, strict]
+ render_errors: false
+ resource_limits:
+ render_length_limit: 46
+ errors:
+ render_error:
+ - Memory limits exceeded
+ hint: |
+ break stops further cells but tablerow must still close the current cell
+ and row. The resulting 47-byte final markup is subject to the render
+ length limit, so a limit of 46 raises a memory error.
+
+- name: for_nonempty_range_at_render_score_boundary
+ complexity: 507
+ template: "{% for i in (1..3) %}{{ i }}{% endfor %}"
+ resource_limits:
+ render_score_limit: 7
+ expected: "123"
+ hint: |
+ A three-item range loop with one rendered node per item costs seven render
+ score units: one loop tag, three yielded values, and three body renders.
+ The exact boundary succeeds and produces all three values.
+
+- name: for_nonempty_range_over_render_score_boundary
+ complexity: 508
+ template: "{% for i in (1..3) %}{{ i }}{% endfor %}"
+ error_mode: [strict2, strict]
+ render_errors: false
+ resource_limits:
+ render_score_limit: 6
+ errors:
+ render_error:
+ - Memory limits exceeded
+ hint: |
+ The same seven-unit nonempty range loop must raise when the render-score
+ limit is six. Check every yielded value and body render against the limit.
+
+- name: for_offset_range_does_not_charge_skipped_items
+ complexity: 509
+ template: "{% for i in (1..10) offset:7 limit:2 %}{{ i }}{% endfor %}"
+ resource_limits:
+ render_score_limit: 5
+ expected: "89"
+ hint: |
+ Offset and limit select only 8 and 9 from this bounded native integer
+ range. Their five score units are the loop tag, two selected values, and
+ two body renders; the seven skipped values must not be charged.
diff --git a/specs/basics/resource-limits.yml b/specs/basics/resource-limits.yml
index ce85347d..56ad04ed 100644
--- a/specs/basics/resource-limits.yml
+++ b/specs/basics/resource-limits.yml
@@ -23,8 +23,10 @@ specs:
render_error:
- Liquid error
hint: |
- Each partial renders a for-loop scoring ~10 points. Per-template limit (100)
- is fine, but cumulative limit (25) is exceeded after the second partial.
+ Each nonempty ten-item partial loop costs 21 render-score units: one loop
+ tag, ten yielded values, and ten body renders. The caller precharges its
+ three render nodes, so the first partial reaches 24 and the second exceeds
+ the cumulative limit of 25 while it is rendering.
- name: cumulative_limits_not_triggered_when_unset
complexity: 1000
diff --git a/specs/liquid_ruby/range_resource_limits.yml b/specs/liquid_ruby/range_resource_limits.yml
new file mode 100644
index 00000000..7a74b81b
--- /dev/null
+++ b/specs/liquid_ruby/range_resource_limits.yml
@@ -0,0 +1,106 @@
+---
+- name: ForTagTest#test_assigned_dynamic_inclusive_and_exclusive_ranges_preserve_slice_semantics_16239422
+ template: "{% assign first = 1 %}{% assign last = 5 %}{% for i in (first..last)
+ offset:-2 limit:4 %}{{ i }}{% endfor %}"
+ render_errors: false
+ expected: '12'
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L539
+ complexity: 1000
+- name: ForTagTest#test_assigned_dynamic_inclusive_and_exclusive_ranges_preserve_slice_semantics_b1b92eca
+ template: "{% for i in numbers %}{{ i }}{% endfor %}"
+ environment:
+ numbers:
+ 'instantiate:Range:':
+ - 1
+ - 5
+ - true
+ render_errors: false
+ expected: '1234'
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L540
+ features:
+ - ruby_drops
+ complexity: 1000
+- name: ForTagTest#test_assigned_dynamic_inclusive_and_exclusive_ranges_preserve_slice_semantics_c8c4a23c
+ template: "{% for i in (1..5) offset:2 limit:-1 %}{{ i }}{% else %}empty{% endfor
+ %}"
+ render_errors: false
+ expected: empty
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L541
+ complexity: 1000
+- name: ForTagTest#test_range_break_does_not_charge_unvisited_items_or_shorten_continuation_541d6967
+ template: "{% for i in (1..10) limit:5 %}{% break %}{% endfor %}{% for i in (1..10)
+ offset:continue limit:2 %}{{ i }}{% endfor %}"
+ render_errors: false
+ expected: '67'
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L532
+ complexity: 1000
+- name: TableRowTest#test_assigned_endless_range_with_a_limit_4423c4c9
+ template: "{% tablerow i in numbers limit:2 %}{{ i }}{% endtablerow %}"
+ environment:
+ numbers:
+ 'instantiate:Range:':
+ - 1
+ -
+ render_errors: false
+ expected: |
+
+ | 1 | 2 |
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L584
+ features:
+ - ruby_drops
+ complexity: 1000
+- name: TableRowTest#test_assigned_endless_range_with_a_limit_9b47203f
+ template: "{% tablerow i in numbers limit:-1 %}{{ i }}{% endtablerow %}"
+ environment:
+ numbers:
+ 'instantiate:Range:':
+ - 1
+ -
+ render_errors: false
+ expected: |
+
+
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L594
+ features:
+ - ruby_drops
+ complexity: 1000
+- name: TableRowTest#test_assigned_endless_range_with_a_limit_cc799bee
+ template: "{% tablerow i in numbers limit:0 %}{{ i }}{% endtablerow %}"
+ environment:
+ numbers:
+ 'instantiate:Range:':
+ - 1
+ -
+ render_errors: false
+ expected: |
+
+
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L589
+ features:
+ - ruby_drops
+ complexity: 1000
+- name: TableRowTest#test_tablerow_assigned_dynamic_and_exclusive_ranges_3afac3d3
+ template: "{% tablerow i in numbers offset:1 %}{{ i }}{% endtablerow %}"
+ environment:
+ numbers:
+ 'instantiate:Range:':
+ - 1
+ - 4
+ - true
+ render_errors: false
+ expected: |
+
+ | 2 | 3 |
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L561
+ features:
+ - ruby_drops
+ complexity: 1000
+- name: TableRowTest#test_tablerow_assigned_dynamic_and_exclusive_ranges_7ce37019
+ template: "{% assign first = 1 %}{% assign last = 4 %}{% tablerow i in (first..last)
+ offset:1 %}{{ i }}{% endtablerow %}"
+ render_errors: false
+ expected: |
+
+ | 2 | 3 | 4 |
+ url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L557
+ complexity: 1000
diff --git a/tasks/helpers.rb b/tasks/helpers.rb
index ac3774c1..b032f616 100644
--- a/tasks/helpers.rb
+++ b/tasks/helpers.rb
@@ -61,11 +61,16 @@ def reset_captures(path)
File.write(path, "---\n", mode: "a+")
end
- def format_and_write_specs(capture_path, outfile, metadata: nil)
+ def format_and_write_specs(capture_path, outfile, metadata: nil, default_complexity: nil)
yaml = File.read(capture_path, encoding: Encoding::UTF_8)
data = YAML.safe_load(yaml, permitted_classes: [Symbol, Date, Time], aliases: true)
- data.each { |spec| annotate_required_features!(spec) }
+ metadata = metadata&.transform_keys(&:to_s)
+ metadata_has_complexity = metadata && (metadata.key?("minimum_complexity") || metadata.key?("complexity"))
+ data.each do |spec|
+ annotate_features!(spec)
+ spec["complexity"] ||= default_complexity if default_complexity && !metadata_has_complexity
+ end
data.sort_by! { |h| h["name"] }
data.uniq!
@@ -77,10 +82,10 @@ def format_and_write_specs(capture_path, outfile, metadata: nil)
private
- # Auto-detect required_features based on environment content.
+ # Auto-detect v2 feature tags based on environment content.
# - ruby_drops: environment uses instantiate: (Drop objects)
# - ruby_types: environment has integer keys or symbol keys
- def annotate_required_features!(spec)
+ def annotate_features!(spec)
features = []
env = spec["environment"]
@@ -89,7 +94,9 @@ def annotate_required_features!(spec)
features << "ruby_types" if has_ruby_type_usage?(env)
end
- spec["required_features"] = features unless features.empty?
+ existing_features = Array(spec["features"])
+ merged_features = (existing_features + features).uniq
+ spec["features"] = merged_features unless merged_features.empty?
end
def has_ruby_type_usage?(value, depth: 0)
diff --git a/test/feature_coverage_baseline.txt b/test/feature_coverage_baseline.txt
index 55fc92a6..9c552481 100644
--- a/test/feature_coverage_baseline.txt
+++ b/test/feature_coverage_baseline.txt
@@ -1,3 +1,4 @@
+range_resource_limits
shopify_blank
shopify_error_format
shopify_error_handling
diff --git a/test/init_test.rb b/test/init_test.rb
index 682d05cd..70dd9c0d 100644
--- a/test/init_test.rb
+++ b/test/init_test.rb
@@ -26,6 +26,7 @@ def test_generated_json_rpc_adapter_uses_context_to_pass_template_id
assert_includes source, "ctx[:adapter].render(ctx[:template_id], assigns, options)"
assert_includes source, "config.error_modes = [:strict2]"
assert_includes source, "config.render_error_modes = [:raise]"
+ assert_includes source, ":range_resource_limits,"
refute_includes source, "LiquidSpec.render do |ctx, template_id, assigns, options|"
assert_valid_ruby(source)
end
diff --git a/test/instantiation_test.rb b/test/instantiation_test.rb
index d4eab025..93650fbb 100644
--- a/test/instantiation_test.rb
+++ b/test/instantiation_test.rb
@@ -16,8 +16,8 @@ def setup
end
Liquid::Spec::ClassRegistry.register("Range") do |params|
- if params.is_a?(Array) && params.size == 2
- Range.new(params[0], params[1])
+ if params.is_a?(Array) && (2..3).cover?(params.size)
+ Range.new(params[0], params[1], params[2] || false)
else
params
end
diff --git a/test/runs_test.rb b/test/runs_test.rb
index c79eee71..d56a1619 100644
--- a/test/runs_test.rb
+++ b/test/runs_test.rb
@@ -20,6 +20,19 @@ def test_default_builtin_adapters_exclude_legacy_liquid_c
assert_equal names.sort, runs.adapter_names.sort
end
+ def test_default_builtin_adapters_skip_staged_range_resource_limit_specs
+ Liquid::Spec::CLI::Runs.default_builtin_adapter_paths.each do |path|
+ assert_includes File.read(path), ":range_resource_limits", "#{File.basename(path)} must skip staged range cases"
+ end
+ end
+
+ def test_explicit_liquid_c_adapters_skip_staged_range_resource_limit_specs
+ %w[liquid_c.rb liquid_c_strict.rb].each do |name|
+ path = File.expand_path("../examples/#{name}", __dir__)
+ assert_includes File.read(path), ":range_resource_limits", "#{name} must skip staged range cases"
+ end
+ end
+
def test_legacy_liquid_c_adapter_remains_explicitly_addressable
runs = Liquid::Spec::CLI::Runs.new
runs.add_adapter("liquid_c")
diff --git a/test/tasks_helpers_test.rb b/test/tasks_helpers_test.rb
new file mode 100644
index 00000000..3e6a7292
--- /dev/null
+++ b/test/tasks_helpers_test.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "date"
+require "tmpdir"
+require "yaml"
+require_relative "../tasks/helpers"
+
+class TasksHelpersTest < Minitest::Test
+ def with_formatted_specs(capture, metadata: nil, default_complexity: nil)
+ Dir.mktmpdir("liquid-spec-helper-test") do |dir|
+ capture_path = File.join(dir, "capture.yml")
+ output_path = File.join(dir, "specs.yml")
+ File.write(capture_path, YAML.dump(capture))
+
+ Helpers.format_and_write_specs(
+ capture_path,
+ output_path,
+ metadata: metadata,
+ default_complexity: default_complexity,
+ )
+ yield YAML.safe_load_file(output_path), Liquid::Spec::SpecLoader.load_file(output_path)
+ end
+ end
+
+ def test_format_preserves_explicit_features_when_adding_inferred_ruby_features
+ capture = [{
+ "name" => "preserves_features",
+ "template" => "{{ range }}",
+ "expected" => "1..5",
+ "features" => ["core"],
+ "environment" => { "range" => { "instantiate:Range:" => [1, 5] } },
+ }]
+
+ with_formatted_specs(capture) do |document, _specs|
+ assert_equal ["core", "ruby_drops"], document.first.fetch("features")
+ end
+ end
+
+ def test_format_preserves_explicit_and_file_metadata_complexity
+ capture = [
+ { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 },
+ { "name" => "metadata_complexity", "template" => "b", "expected" => "b" },
+ ]
+
+ with_formatted_specs(capture, metadata: { "complexity" => 777 }, default_complexity: 1000) do |document, specs|
+ assert_equal 777, document.fetch("_metadata").fetch("complexity")
+ assert_equal 321, document.fetch("specs").first.fetch("complexity")
+ refute document.fetch("specs").last.key?("complexity")
+ assert_equal [321, 777], specs.map(&:complexity)
+ end
+ end
+
+ def test_format_normalizes_symbol_complexity_metadata
+ capture = [
+ { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 },
+ { "name" => "metadata_complexity", "template" => "b", "expected" => "b" },
+ ]
+
+ with_formatted_specs(capture, metadata: { complexity: 777 }, default_complexity: 1000) do |document, specs|
+ assert_equal 777, document.fetch("_metadata").fetch("complexity")
+ refute document.fetch("_metadata").key?(:complexity)
+ refute document.fetch("specs").last.key?("complexity")
+ assert_equal [321, 777], specs.map(&:complexity)
+ end
+ end
+
+ def test_format_string_minimum_complexity_metadata_blocks_per_spec_default
+ assert_metadata_complexity("minimum_complexity" => 777)
+ end
+
+ def test_format_symbol_minimum_complexity_metadata_blocks_per_spec_default
+ assert_metadata_complexity(minimum_complexity: 777)
+ end
+
+ def test_format_applies_an_opt_in_default_only_to_missing_complexity
+ capture = [
+ { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 },
+ { "name" => "default_complexity", "template" => "b", "expected" => "b" },
+ ]
+
+ with_formatted_specs(capture, default_complexity: 1000) do |document, _specs|
+ complexities = document.to_h { |spec| [spec.fetch("name"), spec.fetch("complexity")] }
+ assert_equal 321, complexities.fetch("explicit_complexity")
+ assert_equal 1000, complexities.fetch("default_complexity")
+ end
+ end
+
+ private
+
+ def assert_metadata_complexity(metadata)
+ capture = [
+ { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 },
+ { "name" => "metadata_complexity", "template" => "b", "expected" => "b" },
+ ]
+
+ with_formatted_specs(capture, metadata: metadata, default_complexity: 1000) do |document, specs|
+ assert_equal 777, document.fetch("_metadata").fetch("minimum_complexity")
+ refute document.fetch("_metadata").key?(:minimum_complexity)
+ refute document.fetch("specs").last.key?("complexity")
+ assert_equal [321, 777], specs.map(&:complexity)
+ end
+ end
+end
diff --git a/test/yaml_coders_test.rb b/test/yaml_coders_test.rb
new file mode 100644
index 00000000..9f40959b
--- /dev/null
+++ b/test/yaml_coders_test.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "liquid"
+require "liquid/spec/deps/liquid_ruby"
+
+class YamlCodersTest < Minitest::Test
+ include Liquid::Spec::TestHelpers
+
+ def test_range_capture_round_trips_inclusive_exclusive_and_endless_ranges
+ {
+ "inclusive" => (1..5),
+ "exclusive" => (1...5),
+ "endless" => (1..),
+ }.each do |name, range|
+ captured = YAML.safe_load(YAML.dump({ "range" => range }))
+ instantiated = create_spec(raw_environment: captured).instantiate_environment.fetch("range")
+
+ assert_equal range, instantiated
+ assert_equal range.exclude_end?, instantiated.exclude_end?
+ assert_equal range.begin, instantiated.begin
+ range.end.nil? ? assert_nil(instantiated.end) : assert_equal(range.end, instantiated.end)
+ end
+ end
+
+ def test_inclusive_range_capture_keeps_the_historical_two_item_encoding
+ captured = YAML.safe_load(YAML.dump({ "range" => (1..5) }))
+
+ assert_equal [1, 5], captured.fetch("range").fetch("instantiate:Range:")
+ end
+
+ def test_exclusive_range_capture_appends_the_exclusive_flag
+ captured = YAML.safe_load(YAML.dump({ "range" => (1...5) }))
+
+ assert_equal [1, 5, true], captured.fetch("range").fetch("instantiate:Range:")
+ end
+end