Skip to content

Commit 3e519f2

Browse files
committed
Implement support for objects in context
Resolves #18
1 parent 6966ad7 commit 3e519f2

4 files changed

Lines changed: 115 additions & 26 deletions

File tree

src/Compiler.php

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ private function buildBasePath(bool $data, int $depth): string
682682
if ($data) {
683683
return '$cx->data' . str_repeat("['_parent']", $depth);
684684
}
685-
return $depth > 0 ? "\$cx->depths[count(\$cx->depths)-$depth]" : '$in';
685+
return $depth > 0 ? "(\$cx->depths[count(\$cx->depths)-$depth] ?? null)" : '$in';
686686
}
687687

688688
/**
@@ -707,20 +707,6 @@ private static function buildCallChain(string $fn, string $base, array $parts):
707707
return $expr;
708708
}
709709

710-
/**
711-
* Build a chained array-access string for the given path parts.
712-
* e.g. ['foo', 'bar'] → "['foo']['bar']"
713-
* @param string[] $parts
714-
*/
715-
private static function buildKeyAccess(array $parts): string
716-
{
717-
$n = '';
718-
foreach ($parts as $part) {
719-
$n .= '[' . self::quote($part) . ']';
720-
}
721-
return $n;
722-
}
723-
724710
private function buildBlockHelperCall(string $helperExpr, string $escapedName, BlockStatement $block, string $fn, string $else): string
725711
{
726712
$outerBp = $this->blockParamValues ? '$blockParams' : '[]';
@@ -832,7 +818,7 @@ private function compileModeAwareLookup(string $base, array $parts, bool $scoped
832818
if ($this->options->strict) {
833819
return self::buildCallChain('strictLookup', $base, $parts);
834820
}
835-
return $base . self::buildKeyAccess($parts) . ' ?? null';
821+
return self::buildCallChain('prop', $base, $parts);
836822
}
837823

838824
private function throwKnownHelpersOnly(string $helperName): never

src/Runtime.php

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public static function defaultHelpers(): array
5454
$context = $context($options->scope);
5555
}
5656
if (!is_iterable($context)) {
57-
$context = [];
57+
$context = is_object($context) ? get_object_vars($context) : [];
5858
}
5959
return $options->iterate($context);
6060
},
@@ -109,7 +109,7 @@ public static function defaultHelpers(): array
109109
}
110110

111111
/**
112-
* Strict-mode key lookup: throw if $base is not an array or $key is absent.
112+
* Strict-mode key lookup: throw if $key is absent from $base.
113113
* Unlike the null-coalescing pattern, this allows null values when the key exists.
114114
*/
115115
public static function strictLookup(mixed $base, string $key): mixed
@@ -118,6 +118,8 @@ public static function strictLookup(mixed $base, string $key): mixed
118118
if (array_key_exists($key, $base)) {
119119
return $base[$key];
120120
}
121+
} elseif (is_object($base) && property_exists($base, $key)) {
122+
return $base->$key;
121123
}
122124
$desc = match (true) {
123125
is_bool($base) => $base ? 'true' : 'false',
@@ -130,16 +132,29 @@ public static function strictLookup(mixed $base, string $key): mixed
130132

131133
/**
132134
* assumeObjects / strict-helper-arg key lookup: throw if $base is null (mirroring JS
133-
* TypeError for null/undefined property access); return null silently for a missing key on a
134-
* valid array (mirroring JS returning undefined for a missing object property); return null for
135-
* non-array non-null bases (mirroring JS returning undefined for property access on primitives).
135+
* TypeError for null/undefined property access); return null silently for a missing key or
136+
* a non-array/object base (mirroring JS returning undefined for non-existent properties).
136137
*/
137138
public static function nullCheck(mixed $base, string $key): mixed
138139
{
139140
if ($base === null) {
140-
throw new \ErrorException("Cannot access property \"$key\" on null");
141+
throw new \Exception("Cannot access property \"$key\" on null");
141142
}
142-
return is_array($base) ? ($base[$key] ?? null) : null;
143+
return self::prop($base, $key);
144+
}
145+
146+
/**
147+
* Default key/property lookup for normal mode: $base[$key] for arrays, $base->$key for objects.
148+
*/
149+
public static function prop(mixed $base, string $key): mixed
150+
{
151+
if (is_array($base)) {
152+
return $base[$key] ?? null;
153+
}
154+
if (is_object($base)) {
155+
return $base->$key ?? null;
156+
}
157+
return null;
143158
}
144159

145160
/**
@@ -157,7 +172,7 @@ public static function lookupLength(mixed $base, bool $strict = false): mixed
157172
} elseif (is_string($base)) {
158173
return strlen($base);
159174
} else {
160-
$v = $strict ? self::strictLookup($base, 'length') : null;
175+
$v = $strict ? self::strictLookup($base, 'length') : self::prop($base, 'length');
161176
}
162177
return $v instanceof Closure ? $v() : $v;
163178
}
@@ -216,7 +231,7 @@ public static function lambda(mixed $v): mixed
216231
*/
217232
public static function lookupValue(mixed $_this, string $name, bool $strict = false): mixed
218233
{
219-
$v = $strict ? self::strictLookup($_this, $name) : ($_this[$name] ?? null);
234+
$v = $strict ? self::strictLookup($_this, $name) : self::prop($_this, $name);
220235
return $v instanceof Closure ? $v($_this) : $v;
221236
}
222237

@@ -237,7 +252,7 @@ public static function invokeAmbiguous(RuntimeContext $cx, string $name, mixed &
237252
} elseif ($strict) {
238253
$value = self::strictLookup($_this, $name);
239254
} else {
240-
$value = $assumeObjects ? self::nullCheck($_this, $name) : ($_this[$name] ?? null);
255+
$value = $assumeObjects ? self::nullCheck($_this, $name) : self::prop($_this, $name);
241256
}
242257
if (!$strict) {
243258
$value ??= $cx->helpers['helperMissing'];
@@ -260,11 +275,17 @@ public static function compatLookup(RuntimeContext $cx, mixed $in, string $name,
260275
if (is_array($in) && array_key_exists($name, $in)) {
261276
return $in[$name];
262277
}
278+
if (is_object($in) && property_exists($in, $name)) {
279+
return $in->$name;
280+
}
263281
for ($i = count($cx->depths) - 1; $i >= 0; $i--) {
264282
$ctx = $cx->depths[$i];
265283
if (is_array($ctx) && array_key_exists($name, $ctx)) {
266284
return $ctx[$name];
267285
}
286+
if (is_object($ctx) && property_exists($ctx, $name)) {
287+
return $ctx->$name;
288+
}
268289
}
269290
return $strict ? self::strictLookup(null, $name) : null;
270291
}

tests/ErrorTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ public static function renderErrorProvider(): array
8989
'data' => ['foo' => 42],
9090
'expected' => '"length" not defined in 42',
9191
],
92+
'strict mode .length on object without length property' => [
93+
'template' => '{{foo.length}}',
94+
'options' => new Options(strict: true),
95+
'data' => ['foo' => (object) ['name' => 'test']],
96+
'expected' => '"length" not defined in stdClass',
97+
],
9298
'strict mode null property access in if' => [
9399
'template' => '{{#if foo.bar}}bad{{else}}OK{{/if}}',
94100
'options' => new Options(strict: true),

tests/RegressionTest.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public function testLog(): void
6161
#[DataProvider("ifElseProvider")]
6262
#[DataProvider("sectionProvider")]
6363
#[DataProvider("contextProvider")]
64+
#[DataProvider("objectProvider")]
6465
#[DataProvider("lengthProvider")]
6566
#[DataProvider("dataClosuresProvider")]
6667
#[DataProvider("missingDataProvider")]
@@ -2237,6 +2238,81 @@ public static function contextProvider(): array
22372238
];
22382239
}
22392240

2241+
/** @return array<string, RegIssue> */
2242+
public static function objectProvider(): array
2243+
{
2244+
return [
2245+
'object: single-property root context' => [
2246+
'template' => '{{name}}',
2247+
'data' => (object) ['name' => 'Alice'],
2248+
'expected' => 'Alice',
2249+
],
2250+
'object: nested property lookup' => [
2251+
'template' => '{{user.name}}',
2252+
'data' => ['user' => (object) ['name' => 'Bob']],
2253+
'expected' => 'Bob',
2254+
],
2255+
'object: deeply nested object properties' => [
2256+
'template' => '{{a.b.c}}',
2257+
'data' => ['a' => (object) ['b' => (object) ['c' => 'deep']]],
2258+
'expected' => 'deep',
2259+
],
2260+
'object: missing property renders empty' => [
2261+
'template' => '{{user.missing}}',
2262+
'data' => ['user' => (object) ['name' => 'Carol']],
2263+
'expected' => '',
2264+
],
2265+
'object: {{#with}} scopes to object' => [
2266+
'template' => '{{#with user}}{{name}}{{/with}}',
2267+
'data' => ['user' => (object) ['name' => 'Dan']],
2268+
'expected' => 'Dan',
2269+
],
2270+
'object: {{#each}} iterates public properties' => [
2271+
'template' => '{{#each obj}}{{this}}{{/each}}',
2272+
'data' => ['obj' => (object) ['x' => '1', 'y' => '2']],
2273+
'expected' => '12',
2274+
],
2275+
'object: block section treats object as new scope' => [
2276+
'template' => '{{#obj}}{{name}}{{/obj}}',
2277+
'data' => ['obj' => (object) ['name' => 'Eve']],
2278+
'expected' => 'Eve',
2279+
],
2280+
'object: ../path traversal from inside object scope' => [
2281+
'template' => '{{#with user}}{{../title}}{{/with}}',
2282+
'data' => ['title' => 'Hello', 'user' => (object) ['name' => 'Frank']],
2283+
'expected' => 'Hello',
2284+
],
2285+
'object: block params resolve properties on object items' => [
2286+
'template' => '{{#each items as |item|}}{{item.name}}{{/each}}',
2287+
'data' => ['items' => [(object) ['name' => 'Grace']]],
2288+
'expected' => 'Grace',
2289+
],
2290+
'object: strict mode allows null-valued property' => [
2291+
'template' => '{{user.name}}',
2292+
'options' => new Options(strict: true),
2293+
'data' => ['user' => (object) ['name' => null]],
2294+
'expected' => '',
2295+
],
2296+
'object: assumeObjects mode property access' => [
2297+
'template' => '{{user.name}}',
2298+
'options' => new Options(assumeObjects: true),
2299+
'data' => ['user' => (object) ['name' => 'Henry']],
2300+
'expected' => 'Henry',
2301+
],
2302+
'object: explicit length property' => [
2303+
'template' => '{{obj.length}}',
2304+
'data' => ['obj' => (object) ['length' => 42]],
2305+
'expected' => '42',
2306+
],
2307+
'object: compat mode falls through to parent when property absent from object' => [
2308+
'template' => '{{#each items}}{{name}}{{/each}}',
2309+
'options' => new Options(compat: true),
2310+
'data' => ['name' => 'parent', 'items' => [(object) []]],
2311+
'expected' => 'parent',
2312+
],
2313+
];
2314+
}
2315+
22402316
/** @return array<string, RegIssue> */
22412317
public static function lengthProvider(): array
22422318
{

0 commit comments

Comments
 (0)