Skip to content

Commit 5e99597

Browse files
committed
Add support for strict null coalescence (???) in Antlers parsing
1 parent 8a2269b commit 5e99597

6 files changed

Lines changed: 221 additions & 2 deletions

File tree

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

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

1087+
if ($this->cur == DocumentParser::Punctuation_Question
1088+
&& $this->next == DocumentParser::Punctuation_Question
1089+
&& ($this->currentIndex + 2) < $this->inputLen
1090+
&& $this->chars[$this->currentIndex + 2] == DocumentParser::Punctuation_Question) {
1091+
// ???
1092+
$strictNullCoalesceOperator = new NullCoalesceOperator();
1093+
$strictNullCoalesceOperator->content = '???';
1094+
$strictNullCoalesceOperator->strict = true;
1095+
$strictNullCoalesceOperator->startPosition = $node->lexerRelativeOffset($this->currentIndex);
1096+
$strictNullCoalesceOperator->endPosition = $node->lexerRelativeOffset($this->currentIndex + 3);
1097+
1098+
$this->runtimeNodes[] = $strictNullCoalesceOperator;
1099+
$this->lastNode = $strictNullCoalesceOperator;
1100+
$this->currentIndex += 2;
1101+
1102+
continue;
1103+
}
1104+
10871105
if ($this->cur == DocumentParser::Punctuation_Question && $this->next == DocumentParser::Punctuation_Question) {
10881106
// ??
10891107
$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: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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_modifiers_can_be_called_on_strict_group()
119+
{
120+
$template = <<<'EOT'
121+
{{ (seo_title ??? title) | upper }}
122+
EOT;
123+
124+
$this->assertSame('I AM THE TITLE', $this->renderString($template, [
125+
'seo_title' => null,
126+
'title' => 'i am the title',
127+
], true));
128+
129+
$this->assertSame('I AM THE SEO TITLE', $this->renderString($template, [
130+
'seo_title' => 'i am the seo title',
131+
'title' => 'i am the title',
132+
], true));
133+
}
134+
135+
public function test_strict_null_coalescence_with_multi_path_parts()
136+
{
137+
$data = [
138+
'config' => [
139+
'app' => [
140+
'name' => 'Statamic',
141+
],
142+
],
143+
];
144+
145+
$template = <<<'EOT'
146+
{{ settings:copyright_name ??? config:app:name }}
147+
EOT;
148+
149+
$this->assertSame('Statamic', $this->renderString($template, $data));
150+
151+
$cascade = $this->mock(Cascade::class, function ($m) {
152+
$m->shouldReceive('get')->with('settings')->andReturn(null);
153+
});
154+
155+
$this->assertSame('Statamic', (string) $this->parser()->cascade($cascade)->render($template, $data));
156+
}
157+
158+
public function test_strict_null_coalescence_short_circuits_right_side()
159+
{
160+
$template = <<<'EOT'
161+
{{ hello = "Hello" }}{{ world = "World" }}{{ hello ??? (world = "Earth") }} {{ world }}
162+
EOT;
163+
164+
$this->assertSame('Hello World', $this->renderString($template, [], true));
165+
}
166+
167+
public function test_strict_vs_loose_divergence()
168+
{
169+
$loose = <<<'EOT'
170+
{{ a ?? 'fallback' }}
171+
EOT;
172+
173+
$strict = <<<'EOT'
174+
{{ a ??? 'fallback' }}
175+
EOT;
176+
177+
$falsyValues = [
178+
['a' => 0],
179+
['a' => false],
180+
];
181+
182+
foreach ($falsyValues as $data) {
183+
$this->assertSame('fallback', $this->renderString($loose, $data));
184+
$this->assertNotSame('fallback', $this->renderString($strict, $data));
185+
}
186+
187+
$nullData = ['a' => null];
188+
$this->assertSame('fallback', $this->renderString($loose, $nullData));
189+
$this->assertSame('fallback', $this->renderString($strict, $nullData));
190+
}
191+
}

0 commit comments

Comments
 (0)