Skip to content

Commit 41af339

Browse files
committed
Implement support for objects in context
Resolves #18
1 parent a10f14f commit 41af339

3 files changed

Lines changed: 120 additions & 23 deletions

File tree

src/Compiler.php

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

691691
/**
@@ -712,20 +712,6 @@ private static function buildCallChain(string $fn, string $base, array $parts, ?
712712
return $expr;
713713
}
714714

715-
/**
716-
* Build a chained array-access string for the given path parts.
717-
* e.g. ['foo', 'bar'] → "['foo']['bar']"
718-
* @param string[] $parts
719-
*/
720-
private static function buildKeyAccess(array $parts): string
721-
{
722-
$n = '';
723-
foreach ($parts as $part) {
724-
$n .= '[' . self::quote($part) . ']';
725-
}
726-
return $n;
727-
}
728-
729715
private function buildBlockHelperCall(string $helperExpr, string $escapedName, BlockStatement $block, string $fn, string $else): string
730716
{
731717
$outerBp = $this->blockParamValues ? '$blockParams' : '[]';
@@ -843,7 +829,7 @@ private function compileModeAwareLookup(string $base, array $parts, string $orig
843829
if ($this->options->strict) {
844830
return self::buildCallChain('strictLookup', $base, $parts, self::quote($original));
845831
}
846-
return $base . self::buildKeyAccess($parts) . ' ?? null';
832+
return self::buildCallChain('prop', $base, $parts);
847833
}
848834

849835
private function throwKnownHelpersOnly(string $helperName): never

src/Runtime.php

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ public static function defaultHelpers(): array
5555
}
5656
if ($context instanceof \Traversable) {
5757
$context = iterator_to_array($context);
58+
} elseif (is_object($context)) {
59+
$context = get_object_vars($context);
5860
} elseif (!is_array($context)) {
5961
$context = [];
6062
}
@@ -111,15 +113,19 @@ public static function defaultHelpers(): array
111113
}
112114

113115
/**
114-
* Strict-mode key lookup: throw if $base is not an array or $key is absent.
116+
* Strict-mode key lookup: throw if $key is absent from $base.
115117
* Unlike the null-coalescing pattern, this allows null values when the key exists.
116118
*/
117119
public static function strictLookup(mixed $base, string $key, string $original): mixed
118120
{
119-
if (!is_array($base) || !array_key_exists($key, $base)) {
120-
throw new \Exception('"' . $original . '" not defined');
121+
if (is_array($base)) {
122+
if (array_key_exists($key, $base)) {
123+
return $base[$key];
124+
}
125+
} elseif (is_object($base) && property_exists($base, $key)) {
126+
return $base->$key;
121127
}
122-
return $base[$key];
128+
throw new \Exception('"' . $original . '" not defined');
123129
}
124130

125131
/**
@@ -133,7 +139,27 @@ public static function nullCheck(mixed $base, string $key): mixed
133139
if ($base === null) {
134140
throw new \ErrorException("Cannot access property \"$key\" on null");
135141
}
136-
return is_array($base) ? ($base[$key] ?? null) : null;
142+
if (is_array($base)) {
143+
return $base[$key] ?? null;
144+
}
145+
if (is_object($base)) {
146+
return $base->$key ?? null;
147+
}
148+
return null;
149+
}
150+
151+
/**
152+
* Default key/property lookup for normal mode: $base[$key] for arrays, $base->$key for objects.
153+
*/
154+
public static function prop(mixed $base, string $key): mixed
155+
{
156+
if (is_array($base)) {
157+
return $base[$key] ?? null;
158+
}
159+
if (is_object($base)) {
160+
return $base->$key ?? null;
161+
}
162+
return null;
137163
}
138164

139165
/**
@@ -146,6 +172,9 @@ public static function lookupLength(mixed $base, bool $strict = false): mixed
146172
if (is_array($base)) {
147173
return array_key_exists('length', $base) ? $base['length'] : count($base);
148174
}
175+
if (is_object($base)) {
176+
return $base->length ?? null;
177+
}
149178
if ($strict) {
150179
$desc = match (true) {
151180
$base === null => 'null',
@@ -213,7 +242,7 @@ public static function lambda(mixed $v): mixed
213242
*/
214243
public static function lookupValue(mixed &$_this, string $name, bool $strict = false): mixed
215244
{
216-
$v = $strict ? self::strictLookup($_this, $name, $name) : ($_this[$name] ?? null);
245+
$v = $strict ? self::strictLookup($_this, $name, $name) : self::prop($_this, $name);
217246
return $v instanceof Closure ? $v($_this) : $v;
218247
}
219248

@@ -234,7 +263,7 @@ public static function invokeAmbiguous(RuntimeContext $cx, string $name, mixed &
234263
} elseif ($strict) {
235264
$value = self::strictLookup($_this, $name, $name);
236265
} else {
237-
$value = $assumeObjects ? self::nullCheck($_this, $name) : ($_this[$name] ?? null);
266+
$value = $assumeObjects ? self::nullCheck($_this, $name) : self::prop($_this, $name);
238267
}
239268
if (!$strict) {
240269
$value ??= $cx->helpers['helperMissing'];
@@ -257,11 +286,17 @@ public static function compatLookup(RuntimeContext $cx, mixed $in, string $name,
257286
if (is_array($in) && array_key_exists($name, $in)) {
258287
return $in[$name];
259288
}
289+
if (is_object($in) && property_exists($in, $name)) {
290+
return $in->$name;
291+
}
260292
for ($i = count($cx->depths) - 1; $i >= 0; $i--) {
261293
$ctx = $cx->depths[$i];
262294
if (is_array($ctx) && array_key_exists($name, $ctx)) {
263295
return $ctx[$name];
264296
}
297+
if (is_object($ctx) && property_exists($ctx, $name)) {
298+
return $ctx->$name;
299+
}
265300
}
266301
return $strict ? self::strictLookup(null, $name, $name) : null;
267302
}

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("arrayLengthProvider")]
6566
#[DataProvider("dataClosuresProvider")]
6667
#[DataProvider("missingDataProvider")]
@@ -2228,6 +2229,81 @@ public static function contextProvider(): array
22282229
];
22292230
}
22302231

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

0 commit comments

Comments
 (0)