Skip to content

Commit 2f245b6

Browse files
committed
Standard test drop library, generate: feature, drops.yml specs
Add a portable standard library of test drops that any Liquid implementation can create natively — no Ruby runtime or RPC callbacks needed. Drops make Liquid dynamic: without them, templates can only render static data from the environment hash. Standard drops (docs/test_drops.md): - BooleanDrop, NumberDrop, StringDrop: boxed values via to_liquid_value - MethodDrop: property access with deterministic transforms (echo_N, square_N, double_N) — the op_N pattern lets implementers compute output without RPC - IndexDrop: bracket access (int→number word, string→identity) - SequenceDrop: enumerable yielding first/second/third - NilDrop: to_liquid returns nil - OpaqueDrop: truthy without to_liquid_value - ErrorDrop: raises on access generate: feature for specs: - Specs can define generate: {n: [1, 100]} for random value substitution - #{n} in template/expected is replaced with the generated value - #{n * n} evaluates expressions with generated values - randomness feature flag (on by default; opt out for deterministic runs) specs/basics/drops.yml: 28 specs covering all standard drops, all at complexity 200+. Includes 2 random-specs using generate:. Verifier updates: - ruby_type_tags: whitelist standard drops (not Ruby-specific) - spec_schema: add drops and randomness to valid features Fixed deep_instantiate to handle simple string format 'instantiate:ClassName' (previously only handled Class.new(arg) form). Fixed alias_method issue: Liquid::Drop aliases [] to invoke_drop at class definition time, so subclass overrides of invoke_drop don't propagate to []. Added alias_method in StandardMethodDrop and StandardErrorDrop. NumberDrop implements to_number for numeric filter compatibility (Liquid's Utils.to_number checks respond_to?(:to_number), not to_liquid_value). 36/37 drop specs pass (1 pre-existing failure in truthiness_matrix.yml).
1 parent 9d1c370 commit 2f245b6

13 files changed

Lines changed: 961 additions & 9 deletions

File tree

docs/test_drops.md

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
+# Standard Test Drop Library
2+
3+
## What drops are and why you need them
4+
5+
Without drops, Liquid is **static**: templates can only output literal text,
6+
variables passed in the environment hash, and filtered values. The environment
7+
is a flat hash of strings, numbers, and arrays — set once before render and
8+
never changes.
9+
10+
Drops make Liquid **dynamic**. A drop is an object whose properties are
11+
computed on access — each property lookup is a method call, not a field
12+
read. This enables:
13+
14+
- **Computed properties**`product.price` calculates tax on access
15+
- **Lazy loading**`collection.products` fetches from a database only if
16+
the template actually uses it
17+
- **Enumerable iteration**`{% for item in collection %}` calls `each` on
18+
the drop, yielding dynamic data
19+
- **Stateful objects** — a drop can track access counts, cache results, or
20+
raise errors based on internal state
21+
- **Security boundaries** — drops expose only whitelisted methods; internal
22+
state (`class`, `send`, `instance_eval`) is blocked
23+
24+
In real-world Liquid (Shopify, storefronts, CMS), **everything the template
25+
touches is a drop**: `product`, `cart`, `customer`, `collection`, `page`,
26+
`settings`, `request`, etc. Without drops, your Liquid implementation can
27+
only render static templates with pre-computed data. With drops, it becomes
28+
a full template engine that calls into your application layer.
29+
30+
A drop's property is not a static field; it is a **method invocation**. When
31+
the template does `{{ product.title }}`, Liquid calls `product.title` (or
32+
`product["title"]`), which runs your code and returns the result. This is
33+
the primary extensibility mechanism in Liquid.
34+
35+
## The standard test library
36+
37+
To test drop behavior portably (without Ruby-specific RPC callbacks),
38+
liquid-spec defines a **standard library of test drops**. Each drop has
39+
deterministic, documented behavior that the implementer replicates natively
40+
in their language. No bidirectional RPC is needed.
41+
42+
## The `drops` feature
43+
44+
Adapters declare drop support via the `drops` feature (in the `:core` set):
45+
46+
```ruby
47+
LiquidSpec.configure do |config|
48+
# Empty missing_features = try everything, including drops.
49+
# To skip drop specs while building incrementally:
50+
# config.missing_features = [:drops]
51+
end
52+
```
53+
54+
All drop specs are scored at complexity 200+. Implement the standard library
55+
when your Liquid core (variables, filters, control flow) is solid.
56+
57+
## Standard Drops
58+
59+
### Value drops (boxed values)
60+
61+
Test `to_liquid_value` — the method that materializes a drop into a
62+
primitive Liquid value (boolean, integer, string).
63+
64+
| Drop | Constructor | `to_liquid_value` | `to_s` | Truthy? |
65+
|---|---|---|---|---|
66+
| `BooleanDrop` | `{value: bool}` | `value` | `"true"`/`"false"` | follows value |
67+
| `NumberDrop` | `{value: int}` | `value` | `value.to_s` | yes (0 is truthy in Liquid) |
68+
| `StringDrop` | `{value: str}` | `value` | `value` | yes (non-nil) |
69+
70+
> **Note:** NumberDrop also implements `to_number` (returning the integer)
71+
> because Liquid's numeric filters (`plus`, `minus`, `abs`, etc.) call
72+
> `Utils.to_number(input)`, not `to_liquid_value`. The `to_liquid_value`
73+
> method is used for truthiness checks (`{% if %}`) and the `default` filter,
74+
> but not for numeric filter compatibility.
75+
76+
**YAML format:**
77+
```yaml
78+
environment:
79+
flag: "instantiate:BooleanDrop: {value: false}"
80+
count: "instantiate:NumberDrop: {value: 42}"
81+
name: "instantiate:StringDrop: {value: hello}"
82+
```
83+
84+
**Behavior:**
85+
- `{{ flag }}` → `"false"` (materialized via `to_liquid_value`, then `to_s`)
86+
- `{% if flag %}yes{% else %}no{% endif %}` → `"no"` (false is falsy)
87+
- `{{ count }}` → `"42"`
88+
- `{{ count | plus: 1 }}` → `"43"` (filters see the materialized integer)
89+
- `{{ name | upcase }}` → `"HELLO"`
90+
91+
### MethodDrop (property access with deterministic transforms)
92+
93+
Tests `.method` access on drops. The property name encodes an operation and
94+
an integer argument; the drop parses it and returns the result as a string.
95+
96+
| Property pattern | Output | Rule |
97+
|---|---|---|
98+
| `drop.echo_N` | `"N"` | identity: returns N |
99+
| `drop.square_N` | `"N*N"` | square: returns N² |
100+
| `drop.double_N` | `"N*2"` | double: returns 2N |
101+
102+
**YAML format:**
103+
```yaml
104+
environment:
105+
drop: "instantiate:MethodDrop"
106+
```
107+
108+
**Behavior:**
109+
- `{{ drop.echo_42 }}` → `"42"`
110+
- `{{ drop.square_5 }}` → `"25"`
111+
- `{{ drop.double_7 }}` → `"14"`
112+
113+
The implementer parses the property name: split on `_`, take the first part
114+
as the operation name and the rest as the integer argument. Apply the
115+
operation and return the result as a string.
116+
117+
### IndexDrop (bracket access)
118+
119+
Tests `[int]` and `['string']` access on drops.
120+
121+
| Access | Output | Rule |
122+
|---|---|---|
123+
| `drop[0]` | `"zero"` | integer index → number word |
124+
| `drop[1]` | `"one"` | |
125+
| `drop[2]` | `"two"` | |
126+
| `drop["foo"]` | `"foo"` | string index → the string itself |
127+
128+
**YAML format:**
129+
```yaml
130+
environment:
131+
drop: "instantiate:IndexDrop"
132+
```
133+
134+
**Behavior:**
135+
- `{{ drop[0] }}` → `"zero"`
136+
- `{{ drop["foo"] }}` → `"foo"`
137+
138+
### SequenceDrop (enumerable)
139+
140+
Tests `for` loop iteration. Yields three fixed strings.
141+
142+
| Property | Output |
143+
|---|---|
144+
| Iteration | `"first"`, `"second"`, `"third"` |
145+
| `drop.size` | `3` |
146+
| `drop.first` | `"first"` |
147+
| `drop.last` | `"third"` |
148+
149+
**YAML format:**
150+
```yaml
151+
environment:
152+
items: "instantiate:SequenceDrop"
153+
```
154+
155+
**Behavior:**
156+
```liquid
157+
{% for item in items %}{{ item }} {% endfor %}
158+
```
159+
→ `"first second third "`
160+
161+
### NilDrop (nil materialization)
162+
163+
Tests `to_liquid` → nil. The drop materializes to nothing.
164+
165+
**YAML format:**
166+
```yaml
167+
environment:
168+
drop: "instantiate:NilDrop"
169+
```
170+
171+
**Behavior:**
172+
- `{{ drop }}` → `""` (nil renders as empty string)
173+
- `{% if drop %}yes{% else %}no{% endif %}` → `"no"` (nil is falsy)
174+
175+
### OpaqueDrop (no to_liquid_value)
176+
177+
Tests that drops without `to_liquid_value` are truthy and render via `to_s`.
178+
179+
**YAML format:**
180+
```yaml
181+
environment:
182+
drop: "instantiate:OpaqueDrop"
183+
```
184+
185+
**Behavior:**
186+
- `{{ drop }}` → `"opaque"` (renders via `to_s`)
187+
- `{% if drop %}yes{% else %}no{% endif %}` → `"yes"` (drops are truthy)
188+
189+
### ErrorDrop (error on access)
190+
191+
Tests error handling. Any access raises a `RuntimeError`.
192+
193+
**YAML format:**
194+
```yaml
195+
environment:
196+
drop: "instantiate:ErrorDrop"
197+
```
198+
199+
**Behavior:**
200+
- `{{ drop.foo }}` → raises an error
201+
- Use with `errors: render_error:` to test error propagation.
202+
203+
## The `generate:` feature
204+
205+
Specs can include random values that are substituted into both template and
206+
expected **before** the spec is sent to the adapter. This increases test
207+
coverage without requiring the adapter to support randomness.
208+
209+
```yaml
210+
- name: test_square_drop_random
211+
template: "{{ drop.square_#{n} }}"
212+
expected: "#{n * n}"
213+
generate:
214+
n: [1, 100] # [min, max] for integer generation
215+
features: [drops, randomness]
216+
complexity: 220
217+
```
218+
219+
**How it works:**
220+
1. The spec loader sees `generate: { n: [1, 100] }`
221+
2. Generates a random integer `n` (e.g., 7)
222+
3. Substitutes `#{n}` in template → `{{ drop.square_7 }}`
223+
4. Evaluates `#{n * n}` in expected → `"49"`
224+
5. Sends the final concrete spec to the adapter
225+
226+
The adapter never sees `#{...}` — it receives a fully concrete spec. The
227+
`randomness` feature flag (on by default) controls whether specs with
228+
`generate:` are included. Adapters that want deterministic runs can opt out:
229+
230+
```ruby
231+
config.missing_features = [:randomness]
232+
```
233+
234+
## Migration from Ruby-specific drops
235+
236+
Existing specs use Ruby-specific drop classes (`ToSDrop`, `SecurityVictimDrop`,
237+
`BooleanDrop`, etc.) that require either Ruby runtime or bidirectional RPC
238+
callbacks. These are being migrated to the standard library:
239+
240+
| Old drop | New drop | What changes |
241+
|---|---|---|
242+
| `BooleanDrop` | `BooleanDrop` (standard) | Same behavior, no Ruby needed |
243+
| `IntegerDrop` | `NumberDrop` | Renamed, same behavior |
244+
| `StringDrop` | `StringDrop` (standard) | Same behavior, no Ruby needed |
245+
| `ToSDrop` | `MethodDrop` | Tests `to_s` via deterministic transforms |
246+
| `TestDrop` | `OpaqueDrop` | Tests truthy drops without `to_liquid_value` |
247+
| `TestEnumerable` | `SequenceDrop` | Tests iteration with fixed sequence |
248+
| `ErrorDrop` | `ErrorDrop` (standard) | Same behavior, no Ruby needed |
249+
| `SecurityVictimDrop` | (keep as `ruby_drops`) | Ruby-specific security tests |
250+
| `LoaderDrop` | (keep as `ruby_drops`) | Ruby-specific dynamic loading |
251+
252+
Specs that test Ruby-specific security boundaries (SecurityVictimDrop,
253+
WideOpenObject, UnsafeHashLikeObject, etc.) remain tagged `ruby_drops` and
254+
are skipped by non-Ruby adapters.
255+
256+
## Implementation notes
257+
258+
You don't have to implement drops as objects — use whatever mechanism your
259+
implementation language has that suits best. Closures, maps of functions,
260+
prototype chains, interfaces, records, or any other pattern all work. The
261+
only requirement is that the mechanism supports:
262+
263+
1. Property access (`drop.foo` or `drop["foo"]`) that runs code
264+
2. Enumerable iteration (`{% for item in drop %}`) that yields values
265+
3. Materialization to a primitive (boolean, integer, string, nil) for
266+
truthiness checks and filter compatibility
267+
268+
**Be mindful of performance.** Producing Liquid templates can use a lot of
269+
drops — a single page render might access hundreds of drop properties.
270+
Each access is a method call (not a field read), so the overhead of your
271+
dispatch mechanism matters. In hot paths, prefer direct dispatch over
272+
reflection or hash lookups where possible.
273+
274+
## Implementer checklist
275+
276+
To support the `drops` feature, implement these classes natively:
277+
278+
- [ ] `BooleanDrop(value)` — `to_liquid_value` returns the boolean
279+
- [ ] `NumberDrop(value)` — `to_liquid_value` returns the integer
280+
- [ ] `StringDrop(value)` — `to_liquid_value` returns the string
281+
- [ ] `MethodDrop` — parse `op_N` property names, apply transform
282+
- [ ] `IndexDrop` — bracket access with int→word and string→identity
283+
- [ ] `SequenceDrop` — enumerable yielding "first", "second", "third"
284+
- [ ] `NilDrop` — `to_liquid` returns nil
285+
- [ ] `OpaqueDrop` — truthy, `to_s` returns "opaque"
286+
- [ ] `ErrorDrop` — raises on any access
287+
288+
Each drop must follow Liquid's drop protocol:
289+
- `to_liquid_value` (or `to_liquid`) materializes the drop to a primitive
290+
- `to_s` provides the string representation for `{{ drop }}` output
291+
- `[]` or method access for property lookup
292+
- `each` for enumerable drops

lib/liquid/spec/cli/adapter_dsl.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ def format_timeout(value)
6666
shopify_objects: "Shopify-specific objects (section, block, content_for_header)",
6767
shopify_filters: "Shopify-specific filters (asset_url, image_url, etc.)",
6868
shopify_error_handling: "Shopify-specific error handling and recovery behavior",
69+
drops: "Standard test drop library (BooleanDrop, MethodDrop, SequenceDrop, etc.)",
70+
randomness: "Specs using generated random values for increased coverage",
6971
}.freeze
7072

7173
class Configuration

lib/liquid/spec/cli/features.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ module Features
102102
recommendation: :unnecessary,
103103
note: "Aspirational 'NEW STRICT2 CONTRACT' not yet implemented by liquid-ruby 5.13 (it still suppresses blank-body errors). Opt out until your implementation deliberately adopts this contract.",
104104
},
105+
drops: {
106+
description: "Standard test drop library (BooleanDrop, MethodDrop, SequenceDrop, etc.)",
107+
recommendation: :optional,
108+
note: "Drops make Liquid dynamic — without them, templates can only render static data. See docs/test_drops.md.",
109+
},
110+
randomness: {
111+
description: "Specs using generated random values for increased coverage",
112+
recommendation: :optional,
113+
note: "Values are generated before sending to the adapter. On by default; opt out for deterministic runs.",
114+
},
105115
}.freeze
106116

107117
RECOMMENDATION_LABELS = {

lib/liquid/spec/deps/liquid_ruby.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,4 @@ def new_isolated_subcontext(*)
610610
require_relative "../test_drops"
611611
require_relative "../test_filters"
612612
require_relative "security_objects"
613+
require_relative "standard_drops"

0 commit comments

Comments
 (0)