Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,22 @@ See [COMPLEXITY.md](COMPLEXITY.md) for the full guide with examples.

### Features

Adapters declare which features they support. Suites require specific features to run:
Adapters declare which features they support. Suites and individual specs can require specific features:

```ruby
LiquidSpec.configure do |config|
config.features = [:core, :shopify_tags]
end
```

**Feature expansion:** `:core` automatically expands to include `:runtime_drops`. This means full Liquid implementations that declare `:core` get all features needed for runtime drop callbacks.

**JSON-RPC adapters** that can't support bidirectional communication for runtime drops should declare `features = []` to opt out of `:core` and `:runtime_drops`. They will still run all specs except those requiring `:runtime_drops`.

Common features:
- `:core` - Basic Liquid parsing and rendering
- `:core` - Full Liquid implementation (includes `:runtime_drops`)
- `:runtime_drops` - Supports bidirectional communication for drop callbacks
- `:lax_parsing` - Supports error_mode: :lax for lenient parsing
- `:shopify_tags` - Shopify-specific tags (schema, style, section)
- `:shopify_objects` - Shopify-specific objects (section, block)
- `:shopify_filters` - Shopify-specific filters (asset_url, image_url)
Expand Down
28 changes: 25 additions & 3 deletions lib/liquid/spec/cli/adapter_dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,25 @@
module LiquidSpec
# Standard features that can be declared by adapters
FEATURES = {
core: "Basic Liquid template parsing and rendering",
core: "Full Liquid implementation with runtime drop support",
runtime_drops: "Supports bidirectional communication for drop callbacks",
lax_parsing: "Supports error_mode: :lax for lenient parsing",
shopify_tags: "Shopify-specific tags (schema, style, section, etc.)",
shopify_objects: "Shopify-specific objects (section, block, content_for_header)",
shopify_filters: "Shopify-specific filters (asset_url, image_url, etc.)",
shopify_error_handling: "Shopify-specific error handling and recovery behavior",
}.freeze

# Feature expansions - declaring a feature automatically includes these
# :core is the "full implementation" feature that includes runtime drop support
# JSON-RPC adapters that can't support runtime drops should not declare :core
FEATURE_EXPANSIONS = {
core: [:runtime_drops],
}.freeze

# Default features when no config is set (matches Configuration defaults after expansion)
DEFAULT_FEATURES = [:core, :runtime_drops].freeze

class Configuration
attr_accessor :suite, :filter, :verbose, :strict_only
attr_reader :features
Expand All @@ -43,16 +54,27 @@ def initialize
@verbose = false
@strict_only = false
@features = [:core]
expand_features!
end

def features=(list)
@features = Array(list).map(&:to_sym)
@features << :core unless @features.include?(:core)
expand_features!
end

def feature?(name)
@features.include?(name.to_sym)
end

private

def expand_features!
FEATURE_EXPANSIONS.each do |feature, includes|
if @features.include?(feature)
@features |= includes
end
end
end
end

# Exception raised when an adapter should be skipped
Expand All @@ -74,7 +96,7 @@ def configure
end

def features
@config&.instance_variable_get(:@features) || [:core]
@config&.instance_variable_get(:@features) || DEFAULT_FEATURES
end

# Called once before running specs
Expand Down
35 changes: 35 additions & 0 deletions lib/liquid/spec/deps/liquid_ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@
# Pure drop implementations for testing - track state per-instance
# Each drop is self-contained and produces deterministic output

# ValueDrop - A strict generic drop that wraps any value
# Used to test that filters correctly handle drops with to_s/to_liquid_value
# Raises errors on any unexpected property access (strict mode)
#
# Example YAML:
# {"instantiate:ValueDrop" => "hello"}
# # to_s returns "hello", to_liquid_value returns "hello"
#
# {"instantiate:ValueDrop" => 42}
# # to_s returns "42", to_liquid_value returns 42
class ValueDrop < Liquid::Drop
def initialize(value)
@value = value
end

def to_s
@value.to_s
end

def to_liquid_value
@value
end

# Strict: raise on any unexpected property access
def liquid_method_missing(method)
raise Liquid::UndefinedDropMethod, "ValueDrop does not support method '#{method}'"
end

# Strict: raise on bracket access
def [](key)
raise Liquid::UndefinedDropMethod, "ValueDrop does not support key access '#{key}'"
end
end

class CountingDrop < Liquid::Drop
# A drop that counts how many times [] is accessed.
# to_s returns "N accesses" where N is the count.
Expand Down Expand Up @@ -390,6 +424,7 @@ def encode_with(coder)

# Register all test classes with the ClassRegistry
# Each lambda creates a fresh instance for every test
Liquid::Spec::ClassRegistry.register("ValueDrop") { |p| ValueDrop.new(p) }
Liquid::Spec::ClassRegistry.register("CountingDrop") { |p| CountingDrop.new(p) }
Liquid::Spec::ClassRegistry.register("ToSDrop") { |p| ToSDrop.new(p) }
Liquid::Spec::ClassRegistry.register("TestDrop") { |p| TestDrop.new(p) }
Expand Down
234 changes: 234 additions & 0 deletions specs/liquid_ruby/dynamic_drops.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# Specs that require runtime drop callbacks (bidirectional JSON-RPC)
# These drops cannot be pre-materialized as they track state during rendering
---
_metadata:
hint: |
These specs require runtime drop callbacks - the Liquid implementation must
support bidirectional communication to access drop methods during rendering.
The drops track state (access counts, method calls) that cannot be pre-computed.
minimum_complexity: 300

specs:
# LoaderDrop - tracks each_called and load_slice_called state
- name: ForTagTest#test_iterate_with_each_when_no_limit_applied_4b504dbf
complexity: 300
required_features: [runtime_drops]
template: "{% for item in items %}{{item}}{% endfor %}"
environment:
items:
instantiate:LoaderDrop:
data: [1, 2, 3, 4, 5]
render_errors: false
expected: '12345'
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/for_tag_test.rb#L424

- name: ForTagTest#test_iterate_with_load_slice_returns_same_results_as_without_e99b5d3d
complexity: 300
required_features: [runtime_drops]
template: "{% for item in items offset:2 limit:2 %}{{item}}{% endfor %}"
environment:
items:
instantiate:LoaderDrop:
data: [1, 2, 3, 4, 5]
render_errors: false
expected: '34'
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/for_tag_test.rb#L455

- name: ForTagTest#test_iterate_with_load_slice_when_limit_and_offset_applied_e99b5d3d
complexity: 300
required_features: [runtime_drops]
template: "{% for item in items offset:2 limit:2 %}{{item}}{% endfor %}"
environment:
items:
instantiate:LoaderDrop:
data: [1, 2, 3, 4, 5]
render_errors: false
expected: '34'
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/for_tag_test.rb#L444

- name: ForTagTest#test_iterate_with_load_slice_when_limit_applied_fd17a528
complexity: 300
required_features: [runtime_drops]
template: "{% for item in items limit:1 %}{{item}}{% endfor %}"
environment:
items:
instantiate:LoaderDrop:
data: [1, 2, 3, 4, 5]
render_errors: false
expected: '1'
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/for_tag_test.rb#L434

# ErrorDrop - raises exceptions on method access (tests error handling)
- name: IncludeTagTest#test_render_tag_renders_error_with_template_name_c34cdb86
complexity: 310
required_features: [runtime_drops]
template: "{% include 'foo' with errors %}"
environment:
errors:
instantiate:ErrorDrop: {}
render_errors: true
expected: 'Liquid error (foo line 1): standard error'
filesystem:
foo: "{{ foo.standard_error }}"
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/include_tag_test.rb#L384

- name: IncludeTagTest#test_render_tag_renders_error_with_template_name_from_template_factory_c92c1757
complexity: 320
required_features: [runtime_drops]
template: "{% include 'foo' with errors %}"
environment:
errors:
instantiate:ErrorDrop: {}
render_errors: true
expected: 'Liquid error (some/path/foo line 1): standard error'
template_factory:
instantiate:StubTemplateFactory:
count: 1
filesystem:
foo: "{{ foo.standard_error }}"
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/include_tag_test.rb#L394

- name: RenderTagTest#test_render_tag_renders_error_with_template_name_79f1a430
complexity: 310
required_features: [runtime_drops]
template: "{% render 'foo' with errors %}"
environment:
errors:
instantiate:ErrorDrop: {}
render_errors: true
expected: 'Liquid error (foo line 1): standard error'
filesystem:
foo: "{{ foo.standard_error }}"
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/render_tag_test.rb#L298

- name: RenderTagTest#test_render_tag_renders_error_with_template_name_from_template_factory_8dab9308
complexity: 320
required_features: [runtime_drops]
template: "{% render 'foo' with errors %}"
environment:
errors:
instantiate:ErrorDrop: {}
render_errors: true
expected: 'Liquid error (some/path/foo line 1): standard error'
template_factory:
instantiate:StubTemplateFactory:
count: 1
filesystem:
foo: "{{ foo.standard_error }}"
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/render_tag_test.rb#L308

# CountingDrop - tracks access count (state changes during rendering)
- name: StandardFiltersTest#test_map_calls_to_liquid_ac8bb77d
complexity: 300
required_features: [runtime_drops]
template: '{{ foo | map: "whatever" }}'
environment:
foo:
- instantiate:CountingDrop: {}
render_errors: false
expected: '1 accesses'
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/standard_filter_test.rb#L487

# TestEnumerable - yields items dynamically at runtime
- name: RenderTagTest#test_render_tag_for_drop_cf2ab489
complexity: 300
required_features: [runtime_drops]
template: "{% render 'loop' for loop as value %}"
environment:
loop:
instantiate:TestEnumerable: {}
render_errors: false
expected: '123'
filesystem:
loop: "{{ value.foo }}"
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/render_tag_test.rb#L276

- name: RenderTagTest#test_render_tag_with_drop_e577945d
complexity: 300
required_features: [runtime_drops]
template: "{% render 'loop' with loop as value %}"
environment:
loop:
instantiate:TestEnumerable: {}
render_errors: false
expected: TestEnumerable
filesystem:
loop: "{{ value }}"
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/render_tag_test.rb#L287

- name: StandardFiltersTest#test_sort_works_on_enumerables_83b00155
complexity: 300
required_features: [runtime_drops]
template: '{{ foo | sort: "bar" | map: "foo" }}'
environment:
foo:
instantiate:TestEnumerable: {}
render_errors: false
expected: '213'
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/standard_filter_test.rb#L580

# SettingsDrop - dynamic property access via liquid_method_missing
- name: VariableTest#test_double_nested_variable_lookup_b9f99226
complexity: 300
required_features: [runtime_drops]
template: '{{ list[list[settings.zero]]["foo"] }}'
environment:
list:
- 1
- foo: bar
settings:
instantiate:SettingsDrop:
settings:
zero: 0
bar: foo
render_errors: false
expected: bar
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/variable_test.rb#L168

- name: VariableTest#test_dynamic_find_var_with_drop_4ca2f954
complexity: 300
required_features: [runtime_drops]
template: '{{ [list[settings.zero]["foo"]] }}'
environment:
list:
- foo: bar
settings:
instantiate:SettingsDrop:
settings:
zero: 0
bar: foo
render_errors: false
expected: foo
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/variable_test.rb#L156

- name: VariableTest#test_dynamic_find_var_with_drop_d1fd92c9
complexity: 300
required_features: [runtime_drops]
template: "{{ [list[settings.zero]] }}"
environment:
list:
- foo
settings:
instantiate:SettingsDrop:
settings:
zero: 0
foo: bar
render_errors: false
expected: bar
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/variable_test.rb#L146

# ArrayDrop - Enumerable drop with lazy iteration
- name: TableRowTest#test_enumerable_drop_549595e3
complexity: 300
required_features: [runtime_drops]
template: "{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}"
environment:
numbers:
instantiate:ArrayDrop:
array: [1, 2, 3, 4, 5, 6]
render_errors: false
expected: |
<tr class="row1">
<td class="col1"> 1 </td><td class="col2"> 2 </td><td class="col3"> 3 </td></tr>
<tr class="row2"><td class="col1"> 4 </td><td class="col2"> 5 </td><td class="col3"> 6 </td></tr>
url: https://github.com/Shopify/liquid/blob/584d703aa7552a31d90d09ee6a05416957b7696d/test/integration/tags/table_row_test.rb#L64
Loading