|
| 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 |
0 commit comments