Skip to content

Commit b7ff1c6

Browse files
committed
feat: support selector functions
1 parent 339acdc commit b7ff1c6

File tree

9 files changed

+245
-75
lines changed

9 files changed

+245
-75
lines changed

packages/intl/src/IntlConfig.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Tempest\Intl\Locale;
66
use Tempest\Intl\MessageFormat\FormattingFunction;
7+
use Tempest\Intl\MessageFormat\SelectorFunction;
78

89
final class IntlConfig
910
{
@@ -25,7 +26,7 @@ public function __construct(
2526
public Locale $fallbackLocale,
2627
) {}
2728

28-
public function addFormattingFunction(FormattingFunction $fn): void
29+
public function addFunction(FormattingFunction|SelectorFunction $fn): void
2930
{
3031
$this->functions[] = $fn;
3132
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Tempest\Intl\MessageFormat\Formatter;
4+
5+
use Tempest\Intl\MessageFormat\SelectorFunction;
6+
7+
final class LocalVariable
8+
{
9+
public function __construct(
10+
public readonly string $identifier,
11+
public readonly mixed $value,
12+
public readonly ?SelectorFunction $function = null,
13+
public readonly array $parameters = [],
14+
) {}
15+
}

packages/intl/src/MessageFormat/Formatter/MessageFormatter.php

Lines changed: 95 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
namespace Tempest\Intl\MessageFormat\Formatter;
44

5-
use Exception;
6-
use Tempest\Intl\Locale;
75
use Tempest\Intl\MessageFormat\FormattingFunction;
86
use Tempest\Intl\MessageFormat\Parser\Node\ComplexBody\ComplexBody;
97
use Tempest\Intl\MessageFormat\Parser\Node\ComplexBody\Matcher;
@@ -12,6 +10,7 @@
1210
use Tempest\Intl\MessageFormat\Parser\Node\Declaration\InputDeclaration;
1311
use Tempest\Intl\MessageFormat\Parser\Node\Declaration\LocalDeclaration;
1412
use Tempest\Intl\MessageFormat\Parser\Node\Expression\Expression;
13+
use Tempest\Intl\MessageFormat\Parser\Node\Expression\FunctionCall;
1514
use Tempest\Intl\MessageFormat\Parser\Node\Expression\FunctionExpression;
1615
use Tempest\Intl\MessageFormat\Parser\Node\Expression\LiteralExpression;
1716
use Tempest\Intl\MessageFormat\Parser\Node\Expression\VariableExpression;
@@ -28,17 +27,18 @@
2827
use Tempest\Intl\MessageFormat\Parser\Node\SimpleMessage;
2928
use Tempest\Intl\MessageFormat\Parser\Node\Variable;
3029
use Tempest\Intl\MessageFormat\Parser\Parser;
31-
use Tempest\Intl\PluralRules\PluralRulesMatcher;
30+
use Tempest\Intl\MessageFormat\SelectorFunction;
31+
32+
use function Tempest\Support\arr;
3233

3334
final class MessageFormatter
3435
{
35-
/** @var array<string,mixed> $variables */
36+
/** @var array<string,LocalVariable> $variables */
3637
private array $variables = [];
3738

3839
public function __construct(
3940
/** @var FormattingFunction[] */
4041
private readonly array $functions = [],
41-
private readonly PluralRulesMatcher $pluralRules = new PluralRulesMatcher(),
4242
) {}
4343

4444
/**
@@ -49,7 +49,7 @@ public function format(string $message, mixed ...$variables): string
4949
try {
5050
$ast = new Parser($message)->parse();
5151

52-
$this->variables = $variables;
52+
$this->variables = $this->parseLocalVariables($variables);
5353

5454
return $this->formatMessage($ast, $variables);
5555
} catch (ParsingException $e) {
@@ -72,30 +72,40 @@ private function formatMessage(MessageNode $message): string
7272

7373
foreach ($message->declarations as $declaration) {
7474
if ($declaration instanceof InputDeclaration) {
75-
$variableName = $declaration->expression->variable->name->name;
75+
$expression = $declaration->expression;
76+
$variableName = $expression->variable->name->name;
7677

7778
if (! array_key_exists($variableName, $this->variables)) {
78-
throw new FormattingException("Required input variable '{$variableName}' not provided.");
79+
throw new FormattingException("Required input variable `{$variableName}` not provided.");
80+
}
81+
82+
if ($expression->function instanceof FunctionCall) {
83+
$this->variables[$variableName] = new LocalVariable(
84+
identifier: $variableName,
85+
value: $this->variables[$variableName]->value,
86+
function: $this->getSelectorFunction((string) $expression->function->identifier),
87+
parameters: $this->evaluateOptions($expression->function->options),
88+
);
7989
}
8090
} elseif ($declaration instanceof LocalDeclaration) {
8191
$variableName = $declaration->variable->name->name;
82-
$value = $this->evaluateExpression($declaration->expression);
83-
$localVariables[$variableName] = $value->value;
92+
93+
$localVariables[$variableName] = new LocalVariable(
94+
identifier: $variableName,
95+
value: $this->evaluateExpression($declaration->expression)->value,
96+
function: $this->getSelectorFunction($declaration->expression->function?->identifier),
97+
parameters: $declaration->expression->attributes,
98+
);
8499
}
85100
}
86101

87102
$originalVariables = $this->variables;
88103
$this->variables = [...$this->variables, ...$localVariables];
89104

90105
try {
91-
$result = $this->formatComplexBody($message->body);
92-
$this->variables = $originalVariables;
93-
94-
return $result;
95-
} catch (Exception $e) {
106+
return $this->formatComplexBody($message->body);
107+
} finally {
96108
$this->variables = $originalVariables;
97-
98-
throw $e;
99109
}
100110
}
101111

@@ -121,44 +131,55 @@ private function formatComplexBody(ComplexBody $body): string
121131

122132
private function formatMatcher(Matcher $matcher): string
123133
{
124-
$selectorValues = [];
134+
$selectorVariables = [];
125135

126136
foreach ($matcher->selectors as $selector) {
127137
$variableName = $selector->name->name;
128138

129139
if (! array_key_exists($variableName, $this->variables)) {
130-
throw new FormattingException("Selector variable '{$variableName}' not found.");
140+
throw new FormattingException("Selector variable `{$variableName}` not found.");
131141
}
132142

133-
$selectorValues[] = $this->variables[$variableName];
143+
$selectorVariables[] = $this->variables[$variableName];
134144
}
135145

136-
// Find the best matching variant
137146
$bestVariant = null;
138147
$wildcardVariant = null;
139148

140149
foreach ($matcher->variants as $variant) {
141-
if (count($variant->keys) !== count($selectorValues)) {
142-
continue; // Key count mismatch
150+
if (count($variant->keys) !== count($selectorVariables)) {
151+
continue;
143152
}
144153

145154
$matches = true;
146155
$hasWildcard = false;
147156

148157
for ($i = 0; $i < count($variant->keys); $i++) {
149-
$key = $variant->keys[$i];
150-
$selectorValue = $selectorValues[$i];
158+
$keyNode = $variant->keys[$i];
159+
$variable = $selectorVariables[$i];
151160

152-
if ($key instanceof WildcardKey) {
161+
if ($keyNode instanceof WildcardKey) {
153162
$hasWildcard = true;
154163
continue;
155164
}
156165

157-
if ($key instanceof Literal) {
158-
if (! $this->matchesKey($selectorValue, $key->value)) {
159-
$matches = false;
160-
break;
161-
}
166+
if (! ($keyNode instanceof Literal)) {
167+
$matches = false;
168+
break;
169+
}
170+
171+
$variantKey = $keyNode->value;
172+
$isMatch = false;
173+
174+
if ($variable->function) {
175+
$isMatch = $variable->function->match($variantKey, $variable->value, $variable->parameters);
176+
} else {
177+
$isMatch = $variable->value === $variantKey;
178+
}
179+
180+
if (! $isMatch) {
181+
$matches = false;
182+
break;
162183
}
163184
}
164185

@@ -175,29 +196,15 @@ private function formatMatcher(Matcher $matcher): string
175196
$selectedVariant = $bestVariant ?? $wildcardVariant;
176197

177198
if ($selectedVariant === null) {
199+
$selectorValues = array_column($selectorVariables, 'value');
200+
201+
// TODO: test this
178202
throw new FormattingException('No matching variant found for selector values: ' . json_encode($selectorValues));
179203
}
180204

181205
return $this->formatPattern($selectedVariant->pattern->pattern);
182206
}
183207

184-
private function matchesKey(mixed $value, string $keyValue): bool
185-
{
186-
if (is_numeric($value)) {
187-
$number = (float) $value;
188-
189-
if ($keyValue === ((string) $number) || $keyValue === ((string) ((int) $number))) {
190-
return true;
191-
}
192-
193-
if ($keyValue === $this->pluralRules->getPluralCategory(Locale::default(), $number)) {
194-
return true;
195-
}
196-
}
197-
198-
return ((string) $value) === $keyValue;
199-
}
200-
201208
private function formatPattern(Pattern $pattern): string
202209
{
203210
$result = '';
@@ -245,7 +252,7 @@ private function evaluateExpression(Expression $expression): FormattedValue
245252
throw new FormattingException("Variable `{$variableName}` not found");
246253
}
247254

248-
$value = $this->variables[$variableName];
255+
$value = $this->variables[$variableName]->value;
249256
} elseif ($expression instanceof FunctionExpression) {
250257
$value = null; // Function-only expressions start with null
251258
}
@@ -254,7 +261,7 @@ private function evaluateExpression(Expression $expression): FormattedValue
254261
$functionName = (string) $expression->function->identifier;
255262
$options = $this->evaluateOptions($expression->function->options);
256263

257-
if ($function = $this->getFunction($functionName)) {
264+
if ($function = $this->getFormattingFunction($functionName)) {
258265
return $function->format($value, $options);
259266
} else {
260267
throw new FormattingException("Unknown function `{$functionName}`.");
@@ -266,12 +273,26 @@ private function evaluateExpression(Expression $expression): FormattedValue
266273
return new FormattedValue($value, $formatted);
267274
}
268275

269-
private function getFunction(string $name): ?FormattingFunction
276+
private function getSelectorFunction(?string $name): ?SelectorFunction
270277
{
271-
return array_find(
272-
array: $this->functions,
273-
callback: fn (FormattingFunction $fn) => $fn->name === $name,
274-
);
278+
if (! $name) {
279+
return null;
280+
}
281+
282+
return arr($this->functions)
283+
->filter(fn (FormattingFunction|SelectorFunction $fn) => $fn instanceof SelectorFunction)
284+
->first(fn (SelectorFunction $fn) => $fn->name === $name);
285+
}
286+
287+
private function getFormattingFunction(?string $name): ?FormattingFunction
288+
{
289+
if (! $name) {
290+
return null;
291+
}
292+
293+
return arr($this->functions)
294+
->filter(fn (FormattingFunction|SelectorFunction $fn) => $fn instanceof FormattingFunction)
295+
->first(fn (FormattingFunction $fn) => $fn->name === $name);
275296
}
276297

277298
private function evaluateOptions(array $options): array
@@ -288,7 +309,7 @@ private function evaluateOptions(array $options): array
288309
throw new FormattingException("Option variable `{$variableName}` not found.");
289310
}
290311

291-
$result[$name] = $this->variables[$variableName];
312+
$result[$name] = $this->variables[$variableName]->value;
292313
} elseif ($option->value instanceof Literal) {
293314
$result[$name] = $option->value->value;
294315
}
@@ -297,6 +318,24 @@ private function evaluateOptions(array $options): array
297318
return $result;
298319
}
299320

321+
private function parseLocalVariables(array $variables): array
322+
{
323+
$result = [];
324+
325+
foreach ($variables as $key => $value) {
326+
if ($value instanceof LocalVariable) {
327+
$result[$key] = $value;
328+
} else {
329+
$result[$key] = new LocalVariable(
330+
identifier: $key,
331+
value: $value,
332+
);
333+
}
334+
}
335+
336+
return $result;
337+
}
338+
300339
private function formatMarkup(Markup $markup): string
301340
{
302341
// TODO: more advanced with options

packages/intl/src/MessageFormat/Functions/NumberFunction.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,45 @@
22

33
namespace Tempest\Intl\MessageFormat\Functions;
44

5+
use Tempest\Intl\IntlConfig;
6+
use Tempest\Intl\Locale;
57
use Tempest\Intl\MessageFormat\Formatter\FormattedValue;
68
use Tempest\Intl\MessageFormat\FormattingFunction;
9+
use Tempest\Intl\MessageFormat\SelectorFunction;
710
use Tempest\Intl\Number;
11+
use Tempest\Intl\PluralRules\PluralRulesMatcher;
812
use Tempest\Support\Arr;
913
use Tempest\Support\Currency;
14+
use Tempest\Support\Str;
1015

11-
final class NumberFunction implements FormattingFunction
16+
final class NumberFunction implements FormattingFunction, SelectorFunction
1217
{
1318
public string $name = 'number';
1419

20+
public function __construct(
21+
private readonly IntlConfig $intlConfig,
22+
private readonly PluralRulesMatcher $pluralRules = new PluralRulesMatcher(),
23+
) {}
24+
25+
public function match(string $key, mixed $value, array $parameters): bool
26+
{
27+
$number = Number\parse($value);
28+
29+
if (Arr\get_by_key($parameters, 'select') === 'exact') {
30+
return $key === Str\parse($value);
31+
}
32+
33+
if (Number\parse($key) === $number || $key === $value) {
34+
return true;
35+
}
36+
37+
if ($key === $this->pluralRules->getPluralCategory($this->intlConfig->currentLocale, $number)) {
38+
return true;
39+
}
40+
41+
return false;
42+
}
43+
1544
public function format(mixed $value, array $parameters): FormattedValue
1645
{
1746
$number = Number\parse($value);

packages/intl/src/MessageFormat/Functions/StringFunction.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44

55
use Tempest\Intl\MessageFormat\Formatter\FormattedValue;
66
use Tempest\Intl\MessageFormat\FormattingFunction;
7+
use Tempest\Intl\MessageFormat\SelectorFunction;
78
use Tempest\Support\Arr;
89
use Tempest\Support\Str;
910

10-
final class StringFunction implements FormattingFunction
11+
final class StringFunction implements FormattingFunction, SelectorFunction
1112
{
1213
public string $name = 'string';
1314

15+
public function match(string $key, mixed $value, array $parameters): bool
16+
{
17+
return Str\parse($value, default: '') === $key;
18+
}
19+
1420
public function format(mixed $value, array $parameters): FormattedValue
1521
{
1622
$string = Str\parse($value, default: '');

0 commit comments

Comments
 (0)