diff --git a/src/View/Antlers/Language/Lexer/AntlersLexer.php b/src/View/Antlers/Language/Lexer/AntlersLexer.php index e45b2b84231..167092cba0a 100644 --- a/src/View/Antlers/Language/Lexer/AntlersLexer.php +++ b/src/View/Antlers/Language/Lexer/AntlersLexer.php @@ -1084,6 +1084,26 @@ public function tokenize(AntlersNode $node, $input) continue; } + // Must come before the ?? and ? checks below so ??? isn't + // misread as ?? followed by ?. + if ($this->cur == DocumentParser::Punctuation_Question + && $this->next == DocumentParser::Punctuation_Question + && ($this->currentIndex + 2) < $this->inputLen + && $this->chars[$this->currentIndex + 2] == DocumentParser::Punctuation_Question) { + // ??? + $strictNullCoalesceOperator = new NullCoalesceOperator(); + $strictNullCoalesceOperator->content = '???'; + $strictNullCoalesceOperator->strict = true; + $strictNullCoalesceOperator->startPosition = $node->lexerRelativeOffset($this->currentIndex); + $strictNullCoalesceOperator->endPosition = $node->lexerRelativeOffset($this->currentIndex + 3); + + $this->runtimeNodes[] = $strictNullCoalesceOperator; + $this->lastNode = $strictNullCoalesceOperator; + $this->currentIndex += 2; + + continue; + } + if ($this->cur == DocumentParser::Punctuation_Question && $this->next == DocumentParser::Punctuation_Question) { // ?? $nullCoalesceOperator = new NullCoalesceOperator(); diff --git a/src/View/Antlers/Language/Nodes/Operators/NullCoalesceOperator.php b/src/View/Antlers/Language/Nodes/Operators/NullCoalesceOperator.php index dd5de57c57b..d786b45e684 100644 --- a/src/View/Antlers/Language/Nodes/Operators/NullCoalesceOperator.php +++ b/src/View/Antlers/Language/Nodes/Operators/NullCoalesceOperator.php @@ -7,4 +7,5 @@ class NullCoalesceOperator extends AbstractNode implements OperatorNodeContract { + public bool $strict = false; } diff --git a/src/View/Antlers/Language/Nodes/Structures/NullCoalescenceGroup.php b/src/View/Antlers/Language/Nodes/Structures/NullCoalescenceGroup.php index b55f128a7c8..9de9215e7fa 100644 --- a/src/View/Antlers/Language/Nodes/Structures/NullCoalescenceGroup.php +++ b/src/View/Antlers/Language/Nodes/Structures/NullCoalescenceGroup.php @@ -15,4 +15,6 @@ class NullCoalescenceGroup extends AbstractNode * @var AbstractNode|null */ public $right = null; + + public bool $strict = false; } diff --git a/src/View/Antlers/Language/Parser/LanguageParser.php b/src/View/Antlers/Language/Parser/LanguageParser.php index 63676535b15..9648a41a6c3 100644 --- a/src/View/Antlers/Language/Parser/LanguageParser.php +++ b/src/View/Antlers/Language/Parser/LanguageParser.php @@ -2545,6 +2545,7 @@ private function createNullCoalescenceGroups($tokens) $nullCoalescenceGroup = new NullCoalescenceGroup(); $nullCoalescenceGroup->left = $left; $nullCoalescenceGroup->right = $right; + $nullCoalescenceGroup->strict = $node->strict; $newTokens[] = $nullCoalescenceGroup; $i += 1; diff --git a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php index c74407a1168..76163a5585d 100644 --- a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php +++ b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php @@ -1194,8 +1194,14 @@ private function evaluateNullCoalescence(NullCoalescenceGroup $group) $leftVal = $leftVal->value(); } - if ($leftVal != null) { - return $leftVal; + if ($group->strict) { + if ($leftVal !== null) { + return $leftVal; + } + } else { + if ($leftVal != null) { + return $leftVal; + } } return $this->getValue($group->right); diff --git a/tests/Antlers/Runtime/StrictNullCoalescenceTest.php b/tests/Antlers/Runtime/StrictNullCoalescenceTest.php new file mode 100644 index 00000000000..c04a4940f6a --- /dev/null +++ b/tests/Antlers/Runtime/StrictNullCoalescenceTest.php @@ -0,0 +1,213 @@ +assertSame('fallback', $this->renderString($template, [ + 'a' => null, + 'b' => 'fallback', + ])); + } + + public function test_empty_string_is_preserved() + { + $template = <<<'EOT' +{{ a ??? b }} +EOT; + + $this->assertSame('', $this->renderString($template, [ + 'a' => '', + 'b' => 'fallback', + ])); + } + + public function test_zero_is_preserved() + { + $template = <<<'EOT' +{{ a ??? b }} +EOT; + + $this->assertSame('0', $this->renderString($template, [ + 'a' => 0, + 'b' => 'fallback', + ])); + + $this->assertSame('0', $this->renderString($template, [ + 'a' => '0', + 'b' => 'fallback', + ])); + } + + public function test_false_is_preserved() + { + $template = <<<'EOT' +{{ a ??? b }} +EOT; + + $this->assertSame('', $this->renderString($template, [ + 'a' => false, + 'b' => 'fallback', + ])); + } + + public function test_undefined_variable_falls_through() + { + $template = <<<'EOT' +{{ missing ??? 'fallback' }} +EOT; + + $this->assertSame('fallback', $this->renderString($template)); + } + + public function test_chaining_with_all_null_returns_last() + { + $template = <<<'EOT' +{{ a ??? b ??? 'final' }} +EOT; + + $this->assertSame('final', $this->renderString($template, [ + 'a' => null, + 'b' => null, + ])); + } + + public function test_chaining_returns_first_non_null() + { + $template = <<<'EOT' +{{ a ??? b ??? 'final' }} +EOT; + + $this->assertSame('', $this->renderString($template, [ + 'a' => null, + 'b' => '', + ])); + + $this->assertSame('0', $this->renderString($template, [ + 'a' => null, + 'b' => 0, + ])); + } + + public function test_mixing_with_loose_null_coalescence() + { + $template = <<<'EOT' +{{ a ?? b ??? 'final' }} +EOT; + + $this->assertSame('final', $this->renderString($template, [ + 'a' => '', + 'b' => null, + ])); + + $this->assertSame('B', $this->renderString($template, [ + 'a' => null, + 'b' => 'B', + ])); + } + + public function test_chaining_is_left_associative_with_mixed_operators() + { + // Grouping is (a ??? b) ?? c. The strict inner group preserves 0, + // but the outer loose ?? then treats 0 as null-like and falls through to c. + $template = <<<'EOT' +{{ a ??? b ?? c }} +EOT; + + $this->assertSame('C', $this->renderString($template, [ + 'a' => 0, + 'b' => 'B', + 'c' => 'C', + ])); + + // When the strict inner group returns a truthy value, the outer ?? keeps it. + $this->assertSame('B', $this->renderString($template, [ + 'a' => null, + 'b' => 'B', + 'c' => 'C', + ])); + } + + public function test_modifiers_can_be_called_on_strict_group() + { + $template = <<<'EOT' +{{ (seo_title ??? title) | upper }} +EOT; + + $this->assertSame('I AM THE TITLE', $this->renderString($template, [ + 'seo_title' => null, + 'title' => 'i am the title', + ], true)); + + $this->assertSame('I AM THE SEO TITLE', $this->renderString($template, [ + 'seo_title' => 'i am the seo title', + 'title' => 'i am the title', + ], true)); + } + + public function test_strict_null_coalescence_with_multi_path_parts() + { + $data = [ + 'config' => [ + 'app' => [ + 'name' => 'Statamic', + ], + ], + ]; + + $template = <<<'EOT' +{{ settings:copyright_name ??? config:app:name }} +EOT; + + $this->assertSame('Statamic', $this->renderString($template, $data)); + + $cascade = $this->mock(Cascade::class, function ($m) { + $m->shouldReceive('get')->with('settings')->andReturn(null); + }); + + $this->assertSame('Statamic', (string) $this->parser()->cascade($cascade)->render($template, $data)); + } + + public function test_strict_null_coalescence_short_circuits_right_side() + { + $template = <<<'EOT' +{{ hello = "Hello" }}{{ world = "World" }}{{ hello ??? (world = "Earth") }} {{ world }} +EOT; + + $this->assertSame('Hello World', $this->renderString($template, [], true)); + } + + public function test_strict_vs_loose_divergence() + { + $loose = <<<'EOT' +{{ a ?? 'fallback' }} +EOT; + + $strict = <<<'EOT' +{{ a ??? 'fallback' }} +EOT; + + $falsyValues = [ + ['a' => 0], + ['a' => false], + ]; + + foreach ($falsyValues as $data) { + $this->assertSame('fallback', $this->renderString($loose, $data)); + $this->assertNotSame('fallback', $this->renderString($strict, $data)); + } + + $nullData = ['a' => null]; + $this->assertSame('fallback', $this->renderString($loose, $nullData)); + $this->assertSame('fallback', $this->renderString($strict, $nullData)); + } +}