Skip to content

Commit aded2ff

Browse files
committed
Implement support for objects in context
Resolves #18
1 parent 70e1b7c commit aded2ff

4 files changed

Lines changed: 118 additions & 22 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
/**
@@ -710,20 +710,6 @@ private static function buildCallChain(string $fn, string $base, array $parts):
710710
return $expr;
711711
}
712712

713-
/**
714-
* Build a chained array-access string for the given path parts.
715-
* e.g. ['foo', 'bar'] → "['foo']['bar']"
716-
* @param string[] $parts
717-
*/
718-
private static function buildKeyAccess(array $parts): string
719-
{
720-
$n = '';
721-
foreach ($parts as $part) {
722-
$n .= '[' . self::quote($part) . ']';
723-
}
724-
return $n;
725-
}
726-
727713
private function buildBlockHelperCall(string $helperExpr, string $escapedName, BlockStatement $block, string $fn, string $else): string
728714
{
729715
$outerBp = $this->blockParamValues ? '$blockParams' : '[]';
@@ -841,7 +827,7 @@ private function compileModeAwareLookup(string $base, array $parts, bool $scoped
841827
if ($this->options->strict) {
842828
return self::buildCallChain('strictLookup', $base, $parts);
843829
}
844-
return $base . self::buildKeyAccess($parts) . ' ?? null';
830+
return self::buildCallChain('prop', $base, $parts);
845831
}
846832

847833
private function throwKnownHelpersOnly(string $helperName): never

src/Runtime.php

Lines changed: 34 additions & 6 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',
@@ -139,7 +141,27 @@ public static function nullCheck(mixed $base, string $key): mixed
139141
if ($base === null) {
140142
throw new \ErrorException("Cannot access property \"$key\" on null");
141143
}
142-
return is_array($base) ? ($base[$key] ?? null) : null;
144+
if (is_array($base)) {
145+
return $base[$key] ?? null;
146+
}
147+
if (is_object($base)) {
148+
return $base->$key ?? null;
149+
}
150+
return null;
151+
}
152+
153+
/**
154+
* Default key/property lookup for normal mode: $base[$key] for arrays, $base->$key for objects.
155+
*/
156+
public static function prop(mixed $base, string $key): mixed
157+
{
158+
if (is_array($base)) {
159+
return $base[$key] ?? null;
160+
}
161+
if (is_object($base)) {
162+
return $base->$key ?? null;
163+
}
164+
return null;
143165
}
144166

145167
/**
@@ -154,7 +176,7 @@ public static function lookupLength(mixed $base, bool $strict = false): mixed
154176
if (is_string($base)) {
155177
return strlen($base);
156178
}
157-
return $strict ? self::strictLookup($base, 'length') : null;
179+
return $strict ? self::strictLookup($base, 'length') : self::prop($base, 'length');
158180
}
159181

160182
/**
@@ -211,7 +233,7 @@ public static function lambda(mixed $v): mixed
211233
*/
212234
public static function lookupValue(mixed &$_this, string $name, bool $strict = false): mixed
213235
{
214-
$v = $strict ? self::strictLookup($_this, $name) : ($_this[$name] ?? null);
236+
$v = $strict ? self::strictLookup($_this, $name) : self::prop($_this, $name);
215237
return $v instanceof Closure ? $v($_this) : $v;
216238
}
217239

@@ -232,7 +254,7 @@ public static function invokeAmbiguous(RuntimeContext $cx, string $name, mixed &
232254
} elseif ($strict) {
233255
$value = self::strictLookup($_this, $name);
234256
} else {
235-
$value = $assumeObjects ? self::nullCheck($_this, $name) : ($_this[$name] ?? null);
257+
$value = $assumeObjects ? self::nullCheck($_this, $name) : self::prop($_this, $name);
236258
}
237259
if (!$strict) {
238260
$value ??= $cx->helpers['helperMissing'];
@@ -255,11 +277,17 @@ public static function compatLookup(RuntimeContext $cx, mixed $in, string $name,
255277
if (is_array($in) && array_key_exists($name, $in)) {
256278
return $in[$name];
257279
}
280+
if (is_object($in) && property_exists($in, $name)) {
281+
return $in->$name;
282+
}
258283
for ($i = count($cx->depths) - 1; $i >= 0; $i--) {
259284
$ctx = $cx->depths[$i];
260285
if (is_array($ctx) && array_key_exists($name, $ctx)) {
261286
return $ctx[$name];
262287
}
288+
if (is_object($ctx) && property_exists($ctx, $name)) {
289+
return $ctx->$name;
290+
}
263291
}
264292
return $strict ? self::strictLookup(null, $name) : null;
265293
}

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)