From 022c5a34ce869fdfa9c7e73cb91858e14c02946b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 10 Aug 2026 17:06:23 -0400 Subject: [PATCH] Tighten up variable path resolution Co-Authored-By: Claude Opus 5 --- .../Language/Runtime/PathDataManager.php | 8 +-- .../Fixtures/MethodClasses/CallCounter.php | 7 +++ tests/Antlers/Runtime/MethodCallTest.php | 54 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/View/Antlers/Language/Runtime/PathDataManager.php b/src/View/Antlers/Language/Runtime/PathDataManager.php index 967d72ef6f..1d50a8e1cc 100644 --- a/src/View/Antlers/Language/Runtime/PathDataManager.php +++ b/src/View/Antlers/Language/Runtime/PathDataManager.php @@ -861,9 +861,11 @@ private function reduceVar($path, $processorData = []) } if (is_object($this->reducedVar) && method_exists($this->reducedVar, $method = Str::camel($varPath))) { - if (MethodDenylist::blocks($method)) { - // The method name derives from user-influenceable data, so never - // dispatch to methods that mutate or destroy data. Resolve to null. + // The method name derives from user-influenceable data, so never dispatch to + // methods that mutate or destroy data. Writing `{{ object.method }}` without + // parentheses calls the method just like `{{ object:method() }}` does, so both + // forms honor the `statamic.antlers.allowMethodsInContent` setting. + if (MethodDenylist::blocks($method) || (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent)) { $this->reducedVar = null; $this->didFind = false; $this->doBreak = true; diff --git a/tests/Antlers/Fixtures/MethodClasses/CallCounter.php b/tests/Antlers/Fixtures/MethodClasses/CallCounter.php index 41f051412f..77ef494b48 100644 --- a/tests/Antlers/Fixtures/MethodClasses/CallCounter.php +++ b/tests/Antlers/Fixtures/MethodClasses/CallCounter.php @@ -13,6 +13,13 @@ public function increment() return $this; } + public function incrementTwice() + { + $this->count += 2; + + return $this; + } + public function __toString(): string { return 'Count: '.$this->count; diff --git a/tests/Antlers/Runtime/MethodCallTest.php b/tests/Antlers/Runtime/MethodCallTest.php index d7f4782f13..a240e43215 100644 --- a/tests/Antlers/Runtime/MethodCallTest.php +++ b/tests/Antlers/Runtime/MethodCallTest.php @@ -343,6 +343,60 @@ public function test_method_calls_still_work_in_templates() ], false, true)); } + public function test_implicit_method_calls_blocked_in_user_content() + { + $counter = new CallCounter(); + + // Resolving a variable path to a zero-argument method is still a method + // call, so it honors the same setting as the explicit `:method()` syntax. + $this->assertSame('', $this->renderUserContent('{{ counter.increment }}', ['counter' => $counter])); + $this->assertSame('', $this->renderUserContent('{{ counter:increment }}', ['counter' => $counter])); + + // Str::camel() maps a snake_case path onto a camelCase method. + $this->assertSame('', $this->renderUserContent('{{ counter.increment_twice }}', ['counter' => $counter])); + + $this->assertSame('Count: 0', (string) $counter); + } + + public function test_implicit_method_calls_allowed_in_user_content_when_configured() + { + GlobalRuntimeState::$allowMethodsInContent = true; + + $object = new StringLengthObject('Hello'); + + $this->assertSame('5', $this->renderUserContent('{{ object.length }}', ['object' => $object])); + + GlobalRuntimeState::$allowMethodsInContent = false; + } + + public function test_implicit_method_calls_still_work_in_templates() + { + $object = new StringLengthObject('Hello'); + + $this->assertSame('5', $this->renderString('{{ object.length }}', [ + 'object' => $object, + ], false, true)); + + $this->assertSame('5', $this->renderString('{{ object:length }}', [ + 'object' => $object, + ], false, true)); + } + + private function renderUserContent($content, $data) + { + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($field); + + return $this->renderString('{{ text_field }}', array_merge($data, [ + 'text_field' => new Value($content, 'text_field', $textFieldtype), + ]), false, true); + } + public function test_nested_value_does_not_reset_user_data_flag() { $textFieldtype = new Text();