Skip to content

Commit 540c7ea

Browse files
committed
Implement support for objects in context
Resolves #18
1 parent d7176c9 commit 540c7ea

4 files changed

Lines changed: 120 additions & 24 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: 36 additions & 8 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,20 +141,40 @@ 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
/**
146168
* Terminal .length lookup: returns count() for arrays (since PHP arrays have no native .length
147-
* property), an explicit 'length' key if present, or null for non-array bases.
148-
* When $strict is true, throws for any non-array base, mirroring HBS.js strict-mode behavior.
169+
* property), an explicit 'length' key if present, or null for non-array/non-object bases.
170+
* When $strict is true, throws for objects without a 'length' property and for all scalar/null bases.
149171
*/
150172
public static function lookupLength(mixed $base, bool $strict = false): mixed
151173
{
152174
if (is_array($base)) {
153175
return array_key_exists('length', $base) ? $base['length'] : count($base);
154176
}
155-
return $strict ? self::strictLookup($base, 'length') : null;
177+
return $strict ? self::strictLookup($base, 'length') : self::prop($base, 'length');
156178
}
157179

158180
/**
@@ -209,7 +231,7 @@ public static function lambda(mixed $v): mixed
209231
*/
210232
public static function lookupValue(mixed &$_this, string $name, bool $strict = false): mixed
211233
{
212-
$v = $strict ? self::strictLookup($_this, $name) : ($_this[$name] ?? null);
234+
$v = $strict ? self::strictLookup($_this, $name) : self::prop($_this, $name);
213235
return $v instanceof Closure ? $v($_this) : $v;
214236
}
215237

@@ -230,7 +252,7 @@ public static function invokeAmbiguous(RuntimeContext $cx, string $name, mixed &
230252
} elseif ($strict) {
231253
$value = self::strictLookup($_this, $name);
232254
} else {
233-
$value = $assumeObjects ? self::nullCheck($_this, $name) : ($_this[$name] ?? null);
255+
$value = $assumeObjects ? self::nullCheck($_this, $name) : self::prop($_this, $name);
234256
}
235257
if (!$strict) {
236258
$value ??= $cx->helpers['helperMissing'];
@@ -253,11 +275,17 @@ public static function compatLookup(RuntimeContext $cx, mixed $in, string $name,
253275
if (is_array($in) && array_key_exists($name, $in)) {
254276
return $in[$name];
255277
}
278+
if (is_object($in) && property_exists($in, $name)) {
279+
return $in->$name;
280+
}
256281
for ($i = count($cx->depths) - 1; $i >= 0; $i--) {
257282
$ctx = $cx->depths[$i];
258283
if (is_array($ctx) && array_key_exists($name, $ctx)) {
259284
return $ctx[$name];
260285
}
286+
if (is_object($ctx) && property_exists($ctx, $name)) {
287+
return $ctx->$name;
288+
}
261289
}
262290
return $strict ? self::strictLookup(null, $name) : null;
263291
}

tests/ErrorTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ public static function renderErrorProvider(): array
9595
'data' => ['foo' => 42],
9696
'expected' => '"length" not defined in 42',
9797
],
98+
'strict mode .length on object without length property' => [
99+
'template' => '{{foo.length}}',
100+
'options' => new Options(strict: true),
101+
'data' => ['foo' => (object) ['name' => 'test']],
102+
'expected' => '"length" not defined in stdClass',
103+
],
98104
'strict mode null property access in if' => [
99105
'template' => '{{#if foo.bar}}bad{{else}}OK{{/if}}',
100106
'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("arrayLengthProvider")]
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 arrayLengthProvider(): array
22422318
{

0 commit comments

Comments
 (0)