2424
2525use FormatPHP \Exception \InvalidArgumentException ;
2626use FormatPHP \Exception \UnableToFormatMessageException ;
27- use IntlException as PhpIntlException ;
27+ use FormatPHP \Icu \MessageFormat \Parser ;
28+ use FormatPHP \Icu \MessageFormat \Parser \Type \PluralElement ;
29+ use FormatPHP \Icu \MessageFormat \Parser \Type \SelectElement ;
30+ use FormatPHP \Icu \MessageFormat \Printer ;
2831use Locale as PhpLocale ;
2932use MessageFormatter as PhpMessageFormatter ;
33+ use Ramsey \Collection \Exception \CollectionMismatchException ;
34+ use Throwable ;
3035
36+ use function array_filter ;
37+ use function array_key_exists ;
38+ use function array_keys ;
39+ use function array_values ;
40+ use function assert ;
41+ use function is_callable ;
3142use function is_int ;
43+ use function preg_match ;
3244use function sprintf ;
3345
3446/**
3547 * Formats an ICU message format pattern
3648 */
3749class MessageFormat implements MessageFormatInterface
3850{
51+ private const CALLBACK_REPLACEMENT = '__FORMATPHP_CALLBACK_REPLACEMENT__ ' ;
52+ private const CALLBACK_RESULT_PATTERN = '/(.*) ' . self ::CALLBACK_REPLACEMENT . '(.*)/su ' ;
53+ private const LITERAL_TAG_PATTERN = '/^<(.*)\/>$/su ' ;
54+
3955 private LocaleInterface $ locale ;
4056
4157 /**
@@ -52,10 +68,11 @@ public function __construct(?LocaleInterface $locale = null)
5268 public function format (string $ pattern , array $ values = []): string
5369 {
5470 try {
71+ $ pattern = $ this ->applyCallbacks ($ pattern , $ values );
5572 $ formatter = new PhpMessageFormatter ((string ) $ this ->locale ->baseName (), $ pattern );
5673
5774 return (string ) $ formatter ->format ($ values );
58- } catch (PhpIntlException $ exception ) {
75+ } catch (Throwable $ exception ) {
5976 throw new UnableToFormatMessageException (
6077 sprintf (
6178 'Unable to format message with pattern "%s" for locale "%s" ' ,
@@ -67,4 +84,135 @@ public function format(string $pattern, array $values = []): string
6784 );
6885 }
6986 }
87+
88+ /**
89+ * @param array<array-key, float | int | string | callable(string):string> $values
90+ *
91+ * @throws Parser\Exception\IllegalParserUsageException
92+ * @throws Parser\Exception\InvalidArgumentException
93+ * @throws Parser\Exception\InvalidOffsetException
94+ * @throws Parser\Exception\InvalidSkeletonOption
95+ * @throws Parser\Exception\InvalidUtf8CodeBoundaryException
96+ * @throws Parser\Exception\InvalidUtf8CodePointException
97+ * @throws Parser\Exception\UnableToParseMessageException
98+ * @throws UnableToFormatMessageException
99+ * @throws CollectionMismatchException
100+ */
101+ private function applyCallbacks (string $ pattern , array &$ values = []): string
102+ {
103+ $ callbacks = array_filter ($ values , fn ($ value ): bool => is_callable ($ value ));
104+
105+ // If $values doesn't contain any callables, go ahead and return.
106+ if (!$ callbacks ) {
107+ return $ pattern ;
108+ }
109+
110+ // Remove the callbacks from the values, since we will use them below.
111+ foreach (array_keys ($ callbacks ) as $ key ) {
112+ unset($ values [$ key ]);
113+ }
114+
115+ $ parser = new Parser ($ pattern );
116+ $ parsed = $ parser ->parse ();
117+
118+ if ($ parsed ->err !== null ) {
119+ throw new Parser \Exception \UnableToParseMessageException ($ parsed ->err );
120+ }
121+
122+ assert ($ parsed ->val instanceof Parser \Type \ElementCollection);
123+
124+ return (new Printer ())->printAst ($ this ->processAstWithCallbacks ($ parsed ->val , $ callbacks ));
125+ }
126+
127+ /**
128+ * @param array<array-key, callable(string):string> $callbacks
129+ *
130+ * @throws CollectionMismatchException
131+ * @throws UnableToFormatMessageException
132+ */
133+ private function processAstWithCallbacks (
134+ Parser \Type \ElementCollection $ ast ,
135+ array $ callbacks
136+ ): Parser \Type \ElementCollection {
137+ $ processedAst = new Parser \Type \ElementCollection ();
138+
139+ for ($ i = 0 ; $ i < $ ast ->count (); $ i ++) {
140+ $ element = $ ast [$ i ];
141+ assert ($ element instanceof Parser \Type \ElementInterface);
142+ $ clone = clone $ element ;
143+
144+ if ($ clone instanceof PluralElement || $ clone instanceof SelectElement) {
145+ foreach ($ clone ->options as $ option ) {
146+ $ option ->value = $ this ->processAstWithCallbacks ($ option ->value , $ callbacks );
147+ }
148+ }
149+
150+ if ($ clone instanceof Parser \Type \TagElement) {
151+ $ processedAst = $ processedAst ->merge ($ this ->processTagElement ($ clone , $ callbacks ));
152+
153+ continue ;
154+ }
155+
156+ if ($ clone instanceof Parser \Type \LiteralElement) {
157+ $ clone = $ this ->processLiteralElement ($ clone , $ callbacks );
158+ }
159+
160+ $ processedAst [] = $ clone ;
161+ }
162+
163+ return $ processedAst ;
164+ }
165+
166+ /**
167+ * @param array<array-key, callable(string):string> $callbacks
168+ *
169+ * @throws CollectionMismatchException
170+ * @throws UnableToFormatMessageException
171+ */
172+ private function processTagElement (
173+ Parser \Type \TagElement $ tagElement ,
174+ array $ callbacks
175+ ): Parser \Type \ElementCollection {
176+ if (!array_key_exists ($ tagElement ->value , $ callbacks )) {
177+ // We don't have a callback for this tag.
178+ return new Parser \Type \ElementCollection ([$ tagElement ]);
179+ }
180+
181+ $ result = ($ callbacks [$ tagElement ->value ])(self ::CALLBACK_REPLACEMENT );
182+ if (preg_match (self ::CALLBACK_RESULT_PATTERN , $ result , $ matches )) {
183+ $ start = new Parser \Type \LiteralElement ($ matches [1 ], $ tagElement ->location );
184+ $ middle = $ this ->processAstWithCallbacks ($ tagElement ->children , $ callbacks );
185+ $ end = new Parser \Type \LiteralElement ($ matches [2 ], $ tagElement ->location );
186+
187+ return new Parser \Type \ElementCollection ([$ start , ...array_values ($ middle ->toArray ()), $ end ]);
188+ }
189+
190+ return new Parser \Type \ElementCollection ([new Parser \Type \LiteralElement ($ result , $ tagElement ->location )]);
191+ }
192+
193+ /**
194+ * @param array<array-key, callable(string):string> $callbacks
195+ *
196+ * @throws CollectionMismatchException
197+ * @throws UnableToFormatMessageException
198+ */
199+ private function processLiteralElement (
200+ Parser \Type \LiteralElement $ literalElement ,
201+ array $ callbacks
202+ ): Parser \Type \LiteralElement {
203+ if (!preg_match (self ::LITERAL_TAG_PATTERN , $ literalElement ->value , $ matches )) {
204+ // This isn't a literal tag, so there's nothing to process.
205+ return $ literalElement ;
206+ }
207+
208+ if (!array_key_exists ($ matches [1 ], $ callbacks )) {
209+ // We don't have a callback for this tag.
210+ return $ literalElement ;
211+ }
212+
213+ $ result = ($ callbacks [$ matches [1 ]])('' );
214+ $ literalElement ->value = $ result ;
215+
216+ return $ literalElement ;
217+ }
70218}
0 commit comments