Skip to content

Commit ad76f32

Browse files
authored
feat: bring Locale to parity with FormatJS and ECMA-402 (#11)
1 parent 52db151 commit ad76f32

8 files changed

Lines changed: 678 additions & 42 deletions

File tree

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
"psr/log": "^2",
2424
"ramsey/collection": "^1.2",
2525
"symfony/console": "^5.3",
26-
"webmozart/glob": "^4.4",
27-
"yiisoft/i18n": "^1.0"
26+
"symfony/polyfill-php80": "^1.23",
27+
"webmozart/glob": "^4.4"
2828
},
2929
"require-dev": {
3030
"ramsey/devtools": "^1.7"

src/Intl/Locale.php

Lines changed: 288 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,36 +22,311 @@
2222

2323
namespace FormatPHP\Intl;
2424

25+
use BadMethodCallException;
2526
use FormatPHP\Exception\InvalidArgumentException;
26-
use InvalidArgumentException as PhpInvalidArgumentException;
27-
use Yiisoft\I18n\Locale as YiiLocale;
27+
use Locale as PhpLocale;
28+
29+
use function array_filter;
30+
use function array_values;
31+
use function implode;
32+
use function is_bool;
33+
use function sprintf;
34+
use function str_starts_with;
35+
use function strlen;
36+
use function strtolower;
2837

2938
/**
30-
* FormatPHP locale
39+
* An implementation of an ECMA-402 locale identifier
3140
*/
3241
class Locale implements LocaleInterface
3342
{
34-
private YiiLocale $locale;
43+
private const UNDEFINED_LOCALE = 'und';
44+
45+
/**
46+
* PHP's canonicalization (through ICU) converts calendar values to those
47+
* on the "left" of this map. For ECMA-402 compliance, we convert them back
48+
* to the values on the "right."
49+
*
50+
* @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/calendar
51+
*/
52+
private const CALENDAR_MAP = [
53+
'ethiopic-amete-alem' => 'ethioaa',
54+
'gregorian' => 'gregory',
55+
];
56+
57+
/**
58+
* PHP's canonicalization (through ICU) converts colcasefirst values to those
59+
* on the "left" of this map. For ECMA-402 compliance, we convert them back
60+
* to the values on the "right."
61+
*
62+
* The "false" in this map is intentionally a string value and not boolean.
63+
*
64+
* @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/caseFirst
65+
*/
66+
private const CASE_FIRST_MAP = [
67+
'no' => 'false',
68+
];
69+
70+
/**
71+
* PHP's canonicalization (through ICU) converts collation values to those
72+
* on the "left" of this map. For ECMA-402 compliance, we convert them back
73+
* to the values on the "right."
74+
*
75+
* @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/collation
76+
*/
77+
private const COLLATION_MAP = [
78+
'dictionary' => 'dict',
79+
'gb2312han' => 'gb2312',
80+
'phonebook' => 'phonebk',
81+
'traditional' => 'trad',
82+
];
83+
84+
/**
85+
* PHP's canonicalization (through ICU) converts numbers values to those
86+
* on the "left" of this map. For ECMA-402 compliance, we convert them back
87+
* to the values on the "right."
88+
*
89+
* @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/numberingSystem
90+
*/
91+
private const NUMBERING_SYSTEM_MAP = [
92+
'traditional' => 'traditio',
93+
];
94+
95+
/**
96+
* PHP's canonicalization (through ICU) converts colnumeric values to those
97+
* on the "left" of this map. For ECMA-402 compliance, we convert them back
98+
* to the values on the "right."
99+
*
100+
* These are intentionally string values and not boolean.
101+
*
102+
* @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/numeric
103+
*/
104+
private const NUMERIC_MAP = [
105+
'yes' => 'true',
106+
'no' => 'false',
107+
];
108+
109+
/**
110+
* @var array{language: string | null, script: string | null, region: string | null, variants: array<string>,
111+
* keywords: array<string, string>, grandfathered: string | null}
112+
*/
113+
private array $parsedLocale;
35114

36115
/**
37116
* @throws InvalidArgumentException
38117
*/
39-
public function __construct(string $locale)
118+
public function __construct(string $locale, ?LocaleOptions $options = null)
119+
{
120+
if (strtolower($locale) === self::UNDEFINED_LOCALE) {
121+
$locale = PhpLocale::getDefault();
122+
}
123+
124+
$this->parsedLocale = $this->parseLocale($locale);
125+
126+
if ($options !== null) {
127+
$this->applyOptions($options);
128+
}
129+
}
130+
131+
public function baseName(): ?string
132+
{
133+
if (!$this->parsedLocale['language']) {
134+
return '';
135+
}
136+
137+
$parts = [
138+
$this->parsedLocale['language'],
139+
$this->parsedLocale['script'],
140+
$this->parsedLocale['region'],
141+
...array_values($this->parsedLocale['variants']),
142+
];
143+
144+
return implode('-', array_filter($parts));
145+
}
146+
147+
public function calendar(): ?string
148+
{
149+
$calendar = $this->parsedLocale['keywords']['calendar'] ?? null;
150+
151+
return self::CALENDAR_MAP[$calendar] ?? $calendar;
152+
}
153+
154+
public function caseFirst(): ?string
155+
{
156+
$colcasefirst = $this->parsedLocale['keywords']['colcasefirst'] ?? null;
157+
158+
/** @var "false" | "upper" | "lower" | null */
159+
return self::CASE_FIRST_MAP[$colcasefirst] ?? $colcasefirst;
160+
}
161+
162+
public function collation(): ?string
163+
{
164+
$collation = $this->parsedLocale['keywords']['collation'] ?? null;
165+
166+
return self::COLLATION_MAP[$collation] ?? $collation;
167+
}
168+
169+
public function hourCycle(): ?string
170+
{
171+
/** @var "h11" | "h12" | "h23" | "h24" | null */
172+
return $this->parsedLocale['keywords']['hours'] ?? null;
173+
}
174+
175+
public function language(): ?string
176+
{
177+
return $this->parsedLocale['language'] ?? null;
178+
}
179+
180+
/**
181+
* @return no-return
182+
*
183+
* @throws BadMethodCallException
184+
*/
185+
public function maximize(): LocaleInterface
40186
{
41-
try {
42-
$this->locale = new YiiLocale($locale);
43-
} catch (PhpInvalidArgumentException $exception) {
44-
throw new InvalidArgumentException($exception->getMessage(), (int) $exception->getCode(), $exception);
187+
throw new BadMethodCallException('Method not implemented');
188+
}
189+
190+
/**
191+
* @return no-return
192+
*
193+
* @throws BadMethodCallException
194+
*/
195+
public function minimize(): LocaleInterface
196+
{
197+
throw new BadMethodCallException('Method not implemented');
198+
}
199+
200+
public function numberingSystem(): ?string
201+
{
202+
$numbers = $this->parsedLocale['keywords']['numbers'] ?? null;
203+
204+
return self::NUMBERING_SYSTEM_MAP[$numbers] ?? $numbers;
205+
}
206+
207+
public function numeric(): bool
208+
{
209+
return ($this->parsedLocale['keywords']['colnumeric'] ?? null) === 'yes';
210+
}
211+
212+
public function region(): ?string
213+
{
214+
return $this->parsedLocale['region'] ?? null;
215+
}
216+
217+
public function script(): ?string
218+
{
219+
return $this->parsedLocale['script'] ?? null;
220+
}
221+
222+
public function toString(): string
223+
{
224+
$locale = (string) $this->baseName();
225+
226+
$keywords = '';
227+
foreach ($this->parsedLocale['keywords'] as $keyword => $defaultValue) {
228+
[$key, $value] = $this->getUnicodeKeywordWithValue($keyword, $defaultValue);
229+
if ($value() !== null) {
230+
$keywords .= "-$key-" . (string) $value();
231+
}
232+
}
233+
234+
if (strlen($keywords) > 0) {
235+
$locale .= '-u' . $keywords;
236+
}
237+
238+
return $locale;
239+
}
240+
241+
private function applyOptions(LocaleOptions $options): void
242+
{
243+
$baseProperties = [
244+
'language' => $options->language,
245+
'script' => $options->script,
246+
'region' => $options->region,
247+
];
248+
249+
$keywords = [
250+
'calendar' => $options->calendar,
251+
'colcasefirst' => $options->caseFirst,
252+
'collation' => $options->collation,
253+
'hours' => $options->hourCycle,
254+
'numbers' => $options->numberingSystem,
255+
'colnumeric' => is_bool($options->numeric) ? ($options->numeric ? 'yes' : 'no') : null,
256+
];
257+
258+
$isNotNull = fn (?string $value): bool => $value !== null;
259+
$baseProperties = array_filter($baseProperties, $isNotNull);
260+
$keywords = array_filter($keywords, $isNotNull);
261+
262+
foreach ($baseProperties as $key => $value) {
263+
$this->parsedLocale[$key] = $value;
45264
}
265+
266+
foreach ($keywords as $key => $value) {
267+
$this->parsedLocale['keywords'][$key] = (string) $value;
268+
}
269+
}
270+
271+
/**
272+
* @return array{0: string, 1: callable}
273+
*/
274+
private function getUnicodeKeywordWithValue(string $keyword, string $defaultValue): array
275+
{
276+
$keywordValueMap = [
277+
'calendar' => ['ca', fn (): ?string => $this->calendar()],
278+
'colcasefirst' => ['kf', fn (): ?string => $this->caseFirst()],
279+
'collation' => ['co', fn (): ?string => $this->collation()],
280+
'hours' => ['hc', fn (): ?string => $this->hourCycle()],
281+
'numbers' => ['nu', fn (): ?string => $this->numberingSystem()],
282+
'colnumeric' => ['kn', fn (): ?string => $this->numericValue()],
283+
];
284+
285+
return $keywordValueMap[$keyword] ?? [$keyword, fn (): string => $defaultValue];
46286
}
47287

48-
public function getId(): string
288+
private function numericValue(): ?string
49289
{
50-
return $this->locale->asString();
290+
$colnumeric = $this->parsedLocale['keywords']['colnumeric'] ?? null;
291+
292+
return self::NUMERIC_MAP[$colnumeric] ?? $colnumeric;
51293
}
52294

53-
public function getFallbackLocale(): LocaleInterface
295+
/**
296+
* @throws InvalidArgumentException
297+
*
298+
* @psalm-return array{language: string | null, script: string | null, region: string | null, variants: array<string>, keywords: array<string, string>, grandfathered: string | null}
299+
*/
300+
private function parseLocale(string $locale): array
54301
{
55-
return new self($this->locale->fallbackLocale()->asString());
302+
$canonicalizedLocale = PhpLocale::canonicalize($locale);
303+
304+
/** @var array{language?: string, script?: string, region?: string, grandfathered?: string} $parsed */
305+
$parsed = PhpLocale::parseLocale($canonicalizedLocale);
306+
307+
if ($parsed === []) {
308+
throw new InvalidArgumentException(sprintf('Unable to parse "%s" as a valid locale string', $locale));
309+
}
310+
311+
$variants = [];
312+
foreach ($parsed as $key => $value) {
313+
if (!str_starts_with($key, 'variant')) {
314+
continue;
315+
}
316+
317+
$variants[] = $value;
318+
}
319+
320+
/** @var array<string, string> $keywords */
321+
$keywords = PhpLocale::getKeywords($canonicalizedLocale) ?: [];
322+
323+
return [
324+
'language' => $parsed['language'] ?? self::UNDEFINED_LOCALE,
325+
'script' => $parsed['script'] ?? null,
326+
'region' => $parsed['region'] ?? null,
327+
'grandfathered' => $parsed['grandfathered'] ?? null,
328+
'variants' => $variants,
329+
'keywords' => $keywords,
330+
];
56331
}
57332
}

0 commit comments

Comments
 (0)