Skip to content

Commit 8254238

Browse files
marcorieserclaudejasonvarga
authored
[6.x] Add support for strict null coalescence (???) in Antlers parsing (#14545)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent a14a460 commit 8254238

6 files changed

Lines changed: 245 additions & 2 deletions

File tree

src/View/Antlers/Language/Lexer/AntlersLexer.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,26 @@ public function tokenize(AntlersNode $node, $input)
10841084
continue;
10851085
}
10861086

1087+
// Must come before the ?? and ? checks below so ??? isn't
1088+
// misread as ?? followed by ?.
1089+
if ($this->cur == DocumentParser::Punctuation_Question
1090+
&& $this->next == DocumentParser::Punctuation_Question
1091+
&& ($this->currentIndex + 2) < $this->inputLen
1092+
&& $this->chars[$this->currentIndex + 2] == DocumentParser::Punctuation_Question) {
1093+
// ???
1094+
$strictNullCoalesceOperator = new NullCoalesceOperator();
1095+
$strictNullCoalesceOperator->content = '???';
1096+
$strictNullCoalesceOperator->strict = true;
1097+
$strictNullCoalesceOperator->startPosition = $node->lexerRelativeOffset($this->currentIndex);
1098+
$strictNullCoalesceOperator->endPosition = $node->lexerRelativeOffset($this->currentIndex + 3);
1099+
1100+
$this->runtimeNodes[] = $strictNullCoalesceOperator;
1101+
$this->lastNode = $strictNullCoalesceOperator;
1102+
$this->currentIndex += 2;
1103+
1104+
continue;
1105+
}
1106+
10871107
if ($this->cur == DocumentParser::Punctuation_Question && $this->next == DocumentParser::Punctuation_Question) {
10881108
// ??
10891109
$nullCoalesceOperator = new NullCoalesceOperator();

src/View/Antlers/Language/Nodes/Operators/NullCoalesceOperator.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77

88
class NullCoalesceOperator extends AbstractNode implements OperatorNodeContract
99
{
10+
public bool $strict = false;
1011
}

src/View/Antlers/Language/Nodes/Structures/NullCoalescenceGroup.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ class NullCoalescenceGroup extends AbstractNode
1515
* @var AbstractNode|null
1616
*/
1717
public $right = null;
18+
19+
public bool $strict = false;
1820
}

src/View/Antlers/Language/Parser/LanguageParser.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2545,6 +2545,7 @@ private function createNullCoalescenceGroups($tokens)
25452545
$nullCoalescenceGroup = new NullCoalescenceGroup();
25462546
$nullCoalescenceGroup->left = $left;
25472547
$nullCoalescenceGroup->right = $right;
2548+
$nullCoalescenceGroup->strict = $node->strict;
25482549
$newTokens[] = $nullCoalescenceGroup;
25492550

25502551
$i += 1;

src/View/Antlers/Language/Runtime/Sandbox/Environment.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,8 +1194,14 @@ private function evaluateNullCoalescence(NullCoalescenceGroup $group)
11941194
$leftVal = $leftVal->value();
11951195
}
11961196

1197-
if ($leftVal != null) {
1198-
return $leftVal;
1197+
if ($group->strict) {
1198+
if ($leftVal !== null) {
1199+
return $leftVal;
1200+
}
1201+
} else {
1202+
if ($leftVal != null) {
1203+
return $leftVal;
1204+
}
11991205
}
12001206

12011207
return $this->getValue($group->right);
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<?php
2+
3+
namespace Tests\Antlers\Runtime;
4+
5+
use Statamic\View\Cascade;
6+
use Tests\Antlers\ParserTestCase;
7+
8+
class StrictNullCoalescenceTest extends ParserTestCase
9+
{
10+
public function test_strict_null_falls_through()
11+
{
12+
$template = <<<'EOT'
13+
{{ a ??? b }}
14+
EOT;
15+
16+
$this->assertSame('fallback', $this->renderString($template, [
17+
'a' => null,
18+
'b' => 'fallback',
19+
]));
20+
}
21+
22+
public function test_empty_string_is_preserved()
23+
{
24+
$template = <<<'EOT'
25+
{{ a ??? b }}
26+
EOT;
27+
28+
$this->assertSame('', $this->renderString($template, [
29+
'a' => '',
30+
'b' => 'fallback',
31+
]));
32+
}
33+
34+
public function test_zero_is_preserved()
35+
{
36+
$template = <<<'EOT'
37+
{{ a ??? b }}
38+
EOT;
39+
40+
$this->assertSame('0', $this->renderString($template, [
41+
'a' => 0,
42+
'b' => 'fallback',
43+
]));
44+
45+
$this->assertSame('0', $this->renderString($template, [
46+
'a' => '0',
47+
'b' => 'fallback',
48+
]));
49+
}
50+
51+
public function test_false_is_preserved()
52+
{
53+
$template = <<<'EOT'
54+
{{ a ??? b }}
55+
EOT;
56+
57+
$this->assertSame('', $this->renderString($template, [
58+
'a' => false,
59+
'b' => 'fallback',
60+
]));
61+
}
62+
63+
public function test_undefined_variable_falls_through()
64+
{
65+
$template = <<<'EOT'
66+
{{ missing ??? 'fallback' }}
67+
EOT;
68+
69+
$this->assertSame('fallback', $this->renderString($template));
70+
}
71+
72+
public function test_chaining_with_all_null_returns_last()
73+
{
74+
$template = <<<'EOT'
75+
{{ a ??? b ??? 'final' }}
76+
EOT;
77+
78+
$this->assertSame('final', $this->renderString($template, [
79+
'a' => null,
80+
'b' => null,
81+
]));
82+
}
83+
84+
public function test_chaining_returns_first_non_null()
85+
{
86+
$template = <<<'EOT'
87+
{{ a ??? b ??? 'final' }}
88+
EOT;
89+
90+
$this->assertSame('', $this->renderString($template, [
91+
'a' => null,
92+
'b' => '',
93+
]));
94+
95+
$this->assertSame('0', $this->renderString($template, [
96+
'a' => null,
97+
'b' => 0,
98+
]));
99+
}
100+
101+
public function test_mixing_with_loose_null_coalescence()
102+
{
103+
$template = <<<'EOT'
104+
{{ a ?? b ??? 'final' }}
105+
EOT;
106+
107+
$this->assertSame('final', $this->renderString($template, [
108+
'a' => '',
109+
'b' => null,
110+
]));
111+
112+
$this->assertSame('B', $this->renderString($template, [
113+
'a' => null,
114+
'b' => 'B',
115+
]));
116+
}
117+
118+
public function test_chaining_is_left_associative_with_mixed_operators()
119+
{
120+
// Grouping is (a ??? b) ?? c. The strict inner group preserves 0,
121+
// but the outer loose ?? then treats 0 as null-like and falls through to c.
122+
$template = <<<'EOT'
123+
{{ a ??? b ?? c }}
124+
EOT;
125+
126+
$this->assertSame('C', $this->renderString($template, [
127+
'a' => 0,
128+
'b' => 'B',
129+
'c' => 'C',
130+
]));
131+
132+
// When the strict inner group returns a truthy value, the outer ?? keeps it.
133+
$this->assertSame('B', $this->renderString($template, [
134+
'a' => null,
135+
'b' => 'B',
136+
'c' => 'C',
137+
]));
138+
}
139+
140+
public function test_modifiers_can_be_called_on_strict_group()
141+
{
142+
$template = <<<'EOT'
143+
{{ (seo_title ??? title) | upper }}
144+
EOT;
145+
146+
$this->assertSame('I AM THE TITLE', $this->renderString($template, [
147+
'seo_title' => null,
148+
'title' => 'i am the title',
149+
], true));
150+
151+
$this->assertSame('I AM THE SEO TITLE', $this->renderString($template, [
152+
'seo_title' => 'i am the seo title',
153+
'title' => 'i am the title',
154+
], true));
155+
}
156+
157+
public function test_strict_null_coalescence_with_multi_path_parts()
158+
{
159+
$data = [
160+
'config' => [
161+
'app' => [
162+
'name' => 'Statamic',
163+
],
164+
],
165+
];
166+
167+
$template = <<<'EOT'
168+
{{ settings:copyright_name ??? config:app:name }}
169+
EOT;
170+
171+
$this->assertSame('Statamic', $this->renderString($template, $data));
172+
173+
$cascade = $this->mock(Cascade::class, function ($m) {
174+
$m->shouldReceive('get')->with('settings')->andReturn(null);
175+
});
176+
177+
$this->assertSame('Statamic', (string) $this->parser()->cascade($cascade)->render($template, $data));
178+
}
179+
180+
public function test_strict_null_coalescence_short_circuits_right_side()
181+
{
182+
$template = <<<'EOT'
183+
{{ hello = "Hello" }}{{ world = "World" }}{{ hello ??? (world = "Earth") }} {{ world }}
184+
EOT;
185+
186+
$this->assertSame('Hello World', $this->renderString($template, [], true));
187+
}
188+
189+
public function test_strict_vs_loose_divergence()
190+
{
191+
$loose = <<<'EOT'
192+
{{ a ?? 'fallback' }}
193+
EOT;
194+
195+
$strict = <<<'EOT'
196+
{{ a ??? 'fallback' }}
197+
EOT;
198+
199+
$falsyValues = [
200+
['a' => 0],
201+
['a' => false],
202+
];
203+
204+
foreach ($falsyValues as $data) {
205+
$this->assertSame('fallback', $this->renderString($loose, $data));
206+
$this->assertNotSame('fallback', $this->renderString($strict, $data));
207+
}
208+
209+
$nullData = ['a' => null];
210+
$this->assertSame('fallback', $this->renderString($loose, $nullData));
211+
$this->assertSame('fallback', $this->renderString($strict, $nullData));
212+
}
213+
}

0 commit comments

Comments
 (0)