morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
message.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <array>
6#include <cctype>
7#include <cstddef>
8#include <cstdint>
9#include <expected>
10#include <iterator>
11#include <optional>
12#include <string>
13#include <string_view>
14#include <utility>
15#include <vector>
16
18#include "../util/rational.hpp"
19#include "value.hpp"
20
21/// @file
22/// @brief The MessageFormat 2 subset `ui/1` accepts for `t(key, fallback, args)`, and the CLDR plural rules its
23/// `.match` on a `:number` selects by.
24///
25/// Specified in `docs/spec/ui/expressions.md`, "Messages".
26
27namespace morph::ui {
28
29/// @brief A CLDR plural category.
30enum class PluralCategory : std::uint8_t {
31 Zero, ///< `zero`
32 One, ///< `one`
33 Two, ///< `two`
34 Few, ///< `few`
35 Many, ///< `many`
36 Other, ///< `other`
37};
38
39/// @brief The keyword of a plural category, as a `.match` key spells it.
40/// @param category The category.
41/// @return `zero`, `one`, `two`, `few`, `many` or `other`.
42[[nodiscard]] constexpr std::string_view categoryName(PluralCategory category) {
43 constexpr std::array<std::string_view, 6> kNames = {"zero", "one", "two", "few", "many", "other"};
44 return kNames.at(static_cast<std::size_t>(category));
45}
46
47namespace detail {
48
49/// @brief The decimal text of an exact value at a number of places: rounded half to even, then written with exactly
50/// that many fraction digits, through integer arithmetic only.
51/// @param value The exact value.
52/// @param places The number of fraction digits, at most `math::kMaxDecimalPlaces`.
53/// @return Canonical text `-?[0-9]+(\.[0-9]+)?`; no sign on a value that rounds to zero.
54[[nodiscard]] inline std::string decimalText(math::Rational const& value, std::uint32_t places) {
55 math::Rational const rounded =
57 std::uint64_t const magnitude = math::detail::absU64(rounded.numerator);
58 auto const denominator = static_cast<std::uint64_t>(rounded.denominator);
59 std::string out = rounded.numerator < 0 ? "-" : "";
60 out += std::to_string(magnitude / denominator);
61 // The rounded value is a multiple of 10^-places, so its denominator divides 10^places <= 10^18: the remainder
62 // times ten stays below 10^19, inside uint64, and the expansion ends within `places` digits.
63 std::uint64_t remainder = magnitude % denominator;
64 if (places > 0) {
65 out += '.';
66 for (std::uint32_t digit = 0; digit < places; ++digit) {
67 remainder *= 10U;
68 out += static_cast<char>('0' + static_cast<char>(remainder / denominator));
69 remainder %= denominator;
70 }
71 }
72 return out;
73}
74
75/// @brief The places a number is shown with: an `int` has none, a decimal or quantity its declared places.
76/// @param value The number.
77/// @return The places, or `std::nullopt` when @p value is not a number.
78[[nodiscard]] inline std::optional<std::pair<math::Rational, std::uint32_t>> numberOf(Value const& value) {
79 if (std::int64_t const* const whole = value.asInt()) {
80 return std::pair{math::Rational{*whole, math::DecimalPlaces{0}}, 0U};
81 }
82 if (Decimal const* const decimal = value.asDecimal()) {
83 return std::pair{decimal->value, decimal->value.decimalPlaces.value};
84 }
85 if (Quantity const* const quantity = value.asQuantity()) {
86 return std::pair{quantity->value, quantity->value.decimalPlaces.value};
87 }
88 return std::nullopt;
89}
90
91} // namespace detail
92
93/// @brief Formats a number for display in a locale: at its own places, rounded half to even.
94/// @param value An `int`, `decimal` or `quantity` (its magnitude).
95/// @param numeric The locale's separators, sign and digits.
96/// @return The display text, or `std::nullopt` when @p value is not a number.
97[[nodiscard]] inline std::optional<std::string> formatNumber(Value const& value,
98 render::NumericLocale const& numeric) {
99 auto const number = detail::numberOf(value);
100 if (!number) {
101 return std::nullopt;
102 }
103 return render::formatCanonicalNumber(detail::decimalText(number->first, number->second), numeric);
104}
105
106namespace detail {
107
108/// @brief CLDR's plural operands of a number as it is shown.
110 /// @brief `i`: the integer digits of the absolute value.
111 std::uint64_t integer = 0;
112 /// @brief Whether `n` has no fraction.
113 bool whole = false;
114 /// @brief `v != 0`: whether the number shows fraction digits.
115 bool visibleFraction = false;
116};
117
118/// @brief Whether an operand lies in a CLDR range `low..high`.
119/// @param operand The operand.
120/// @param low The range's start.
121/// @param high The range's end.
122/// @return Whether `low <= operand <= high`.
123[[nodiscard]] constexpr bool between(std::uint64_t operand, std::uint64_t low, std::uint64_t high) noexcept {
124 return operand >= low && operand <= high;
125}
126
127/// @brief CLDR's `many` of the Romance languages: an integer that is a non-zero multiple of a million.
128/// @param n The operands.
129/// @return Whether the number is a whole million.
130[[nodiscard]] constexpr bool wholeMillion(PluralOperands const& n) noexcept {
131 return !n.visibleFraction && n.integer != 0 && n.integer % 1000000U == 0;
132}
133
134/// @brief `en`, `de`, `nl`, `sv`: `one` is `i = 1 and v = 0`.
135/// @param n The operands.
136/// @return The category.
137[[nodiscard]] constexpr PluralCategory germanicPlural(PluralOperands const& n) noexcept {
138 return n.integer == 1 && !n.visibleFraction ? PluralCategory::One : PluralCategory::Other;
139}
140
141/// @brief `it`: as English, and `many` for a whole million.
142/// @param n The operands.
143/// @return The category.
144[[nodiscard]] constexpr PluralCategory italianPlural(PluralOperands const& n) noexcept {
145 if (n.integer == 1 && !n.visibleFraction) {
146 return PluralCategory::One;
147 }
149}
150
151/// @brief `es`: `one` is `n = 1`, and `many` a whole million.
152/// @param n The operands.
153/// @return The category.
154[[nodiscard]] constexpr PluralCategory spanishPlural(PluralOperands const& n) noexcept {
155 if (n.whole && n.integer == 1) {
156 return PluralCategory::One;
157 }
159}
160
161/// @brief `fr`, `pt`: `one` is `i = 0..1`, and `many` a whole million.
162/// @param n The operands.
163/// @return The category.
164[[nodiscard]] constexpr PluralCategory frenchPlural(PluralOperands const& n) noexcept {
165 if (n.integer <= 1) {
166 return PluralCategory::One;
167 }
169}
170
171/// @brief `pl`: `one`, `few` for 2..4 outside 12..14, `many` for other integers, `other` for fractions.
172/// @param n The operands.
173/// @return The category.
174[[nodiscard]] constexpr PluralCategory polishPlural(PluralOperands const& n) noexcept {
175 if (n.visibleFraction) {
177 }
178 if (n.integer == 1) {
179 return PluralCategory::One;
180 }
181 if (between(n.integer % 10U, 2, 4) && !between(n.integer % 100U, 12, 14)) {
182 return PluralCategory::Few;
183 }
185}
186
187/// @brief `ru`, `uk`: `one` for 1 outside 11, `few` for 2..4 outside 12..14, `many` for other integers.
188/// @param n The operands.
189/// @return The category.
190[[nodiscard]] constexpr PluralCategory eastSlavicPlural(PluralOperands const& n) noexcept {
191 if (n.visibleFraction) {
193 }
194 if (n.integer % 10U == 1 && n.integer % 100U != 11) {
195 return PluralCategory::One;
196 }
197 if (between(n.integer % 10U, 2, 4) && !between(n.integer % 100U, 12, 14)) {
198 return PluralCategory::Few;
199 }
201}
202
203/// @brief `cs`: `one`, `few` for 2..4, `many` for fractions.
204/// @param n The operands.
205/// @return The category.
206[[nodiscard]] constexpr PluralCategory czechPlural(PluralOperands const& n) noexcept {
207 if (n.visibleFraction) {
209 }
210 if (n.integer == 1) {
211 return PluralCategory::One;
212 }
213 return between(n.integer, 2, 4) ? PluralCategory::Few : PluralCategory::Other;
214}
215
216/// @brief `ar`: `zero`, `one`, `two`, `few` for 3..10 and `many` for 11..99 (both modulo 100).
217/// @param n The operands.
218/// @return The category.
219[[nodiscard]] constexpr PluralCategory arabicPlural(PluralOperands const& n) noexcept {
220 if (!n.whole) {
222 }
223 if (n.integer == 0) {
225 }
226 if (n.integer == 1) {
227 return PluralCategory::One;
228 }
229 if (n.integer == 2) {
230 return PluralCategory::Two;
231 }
232 if (between(n.integer % 100U, 3, 10)) {
233 return PluralCategory::Few;
234 }
235 return between(n.integer % 100U, 11, 99) ? PluralCategory::Many : PluralCategory::Other;
236}
237
238/// @brief A language's plural rule.
239using PluralRule = PluralCategory (*)(PluralOperands const&) noexcept;
240
241/// @brief The languages whose rules are carried, by language subtag.
242inline constexpr std::array<std::pair<std::string_view, PluralRule>, 13> kPluralRules = {{
243 {"en", &germanicPlural},
244 {"de", &germanicPlural},
245 {"nl", &germanicPlural},
246 {"sv", &germanicPlural},
247 {"it", &italianPlural},
248 {"es", &spanishPlural},
249 {"fr", &frenchPlural},
250 {"pt", &frenchPlural},
251 {"pl", &polishPlural},
252 {"ru", &eastSlavicPlural},
253 {"uk", &eastSlavicPlural},
254 {"cs", &czechPlural},
255 {"ar", &arabicPlural},
256}};
257
258} // namespace detail
259
260/// @brief The CLDR plural category of a number in a locale.
261///
262/// The operands are CLDR's: `n` the absolute value, `i` its integer digits, `v` the count of visible fraction
263/// digits (the decimal's places; an `int` has none). Rules are carried for `en`, `de`, `nl`, `sv`, `it`, `es`,
264/// `pt`, `fr`, `pl`, `ru`, `uk`, `cs` and `ar`; `ja`, `zh`, `ko` and every other language select only `other`. The
265/// locale's language subtag decides, case-insensitively, so `pl-PL` uses the Polish rules.
266/// @param locale A BCP 47 locale, such as `pl-PL`.
267/// @param value The exact number.
268/// @param places The visible fraction digits.
269/// @return The category.
270[[nodiscard]] inline PluralCategory pluralCategory(std::string_view locale, math::Rational const& value,
271 std::uint32_t places) {
272 std::string language;
273 for (char const chr : locale.substr(0, locale.find_first_of("-_"))) {
274 language += static_cast<char>(std::tolower(static_cast<unsigned char>(chr)));
275 }
276 math::Rational const shown =
278 detail::PluralOperands const operands{
279 .integer = math::detail::absU64(shown.numerator) / static_cast<std::uint64_t>(shown.denominator),
280 .whole = shown.denominator == 1,
281 .visibleFraction = places != 0,
282 };
283 auto const index = std::ranges::distance(
284 detail::kPluralRules.begin(),
285 std::ranges::find(detail::kPluralRules, language, &std::pair<std::string_view, detail::PluralRule>::first));
286 if (std::cmp_equal(index, detail::kPluralRules.size())) {
288 }
289 return std::next(detail::kPluralRules.begin(), index)->second(operands);
290}
291
292/// @brief The formatting function a placeholder or a declaration names.
293enum class MessageFunction : std::uint8_t {
294 None, ///< No annotation: a string as it is, a number as `:number` formats it.
295 Number, ///< `:number`: locale number formatting, and plural selection.
296 String, ///< `:string`: the text, and selection by exact match.
297};
298
299/// @brief One part of a pattern: literal text, or a placeholder of a variable.
301 /// @brief The literal text; empty for a placeholder.
302 std::string text;
303 /// @brief The variable a placeholder reads, without `$`; empty for text.
304 std::string variable;
305 /// @brief The placeholder's annotation.
307};
308
309/// @brief One variant of a `.match`: a key per selector (`*` is the catch-all) and its pattern.
311 /// @brief The keys, one per selector.
312 std::vector<std::string> keys;
313 /// @brief The pattern shown when the variant is selected.
314 std::vector<MessagePart> pattern;
315};
316
317/// @brief A parsed message in the `ui/1` subset of MessageFormat 2.
319 /// @brief The `.input` declarations: each variable with its function.
320 std::vector<std::pair<std::string, MessageFunction>> inputs;
321 /// @brief The `.match` selectors, as variable names; empty for a message without `.match`.
322 std::vector<std::string> selectors;
323 /// @brief The variants of a `.match`.
324 std::vector<MessageVariant> variants;
325 /// @brief The pattern of a message without `.match`.
326 std::vector<MessagePart> pattern;
327};
328
329namespace detail {
330
331/// @brief A recursive-descent reader over one message's text.
333public:
334 /// @brief Reads @p text.
335 /// @param text The message source.
336 explicit MessageReader(std::string_view text) : _text{text} {}
337
338 /// @brief Parses the whole message.
339 /// @return The pattern, or the reason it is outside the subset.
340 [[nodiscard]] std::expected<MessagePattern, std::string> parse() {
341 MessagePattern message;
342 std::size_t const start = _pos;
343 skipSpace();
344 if (!atEnd() && (peek() == '.' || _text.substr(_pos).starts_with("{{"))) {
345 return parseComplex(message);
346 }
347 _pos = start;
348 auto pattern = parsePattern(false);
349 if (!pattern) {
350 return std::unexpected(pattern.error());
351 }
352 message.pattern = std::move(*pattern);
353 return message;
354 }
355
356private:
357 [[nodiscard]] bool atEnd() const noexcept { return _pos >= _text.size(); }
358 [[nodiscard]] char peek() const { return _text.at(_pos); }
359
360 void skipSpace() {
361 while (!atEnd() && (peek() == ' ' || peek() == '\t' || peek() == '\n' || peek() == '\r')) {
362 ++_pos;
363 }
364 }
365
366 [[nodiscard]] static bool nameChar(char chr, bool first) noexcept {
367 bool const letter = (chr >= 'a' && chr <= 'z') || (chr >= 'A' && chr <= 'Z') || chr == '_';
368 if (first) {
369 return letter;
370 }
371 return letter || (chr >= '0' && chr <= '9') || chr == '-' || chr == '.';
372 }
373
374 [[nodiscard]] std::string readName() {
375 std::size_t const start = _pos;
376 while (!atEnd() && nameChar(peek(), _pos == start)) {
377 ++_pos;
378 }
379 return std::string{_text.substr(start, _pos - start)};
380 }
381
382 /// Reads `$name`; the caller has seen the `$`.
383 [[nodiscard]] std::expected<std::string, std::string> readVariable() {
384 ++_pos;
385 std::string name = readName();
386 if (name.empty()) {
387 return std::unexpected(std::string{"a variable needs a name after `$`"});
388 }
389 return name;
390 }
391
392 /// Reads `{ $var [:number|:string] }` after the opening brace has been consumed.
393 [[nodiscard]] std::expected<std::pair<std::string, MessageFunction>, std::string> readExpressionBody() {
394 skipSpace();
395 if (atEnd()) {
396 return std::unexpected(std::string{"a placeholder is not closed"});
397 }
398 if (peek() != '$') {
399 return std::unexpected(std::string{"only variable placeholders `{$name}` are in the ui/1 subset"});
400 }
401 auto variable = readVariable();
402 if (!variable) {
403 return std::unexpected(variable.error());
404 }
405 skipSpace();
406 MessageFunction function = MessageFunction::None;
407 if (!atEnd() && peek() == ':') {
408 ++_pos;
409 std::string const name = readName();
410 if (name == "number") {
411 function = MessageFunction::Number;
412 } else if (name == "string") {
413 function = MessageFunction::String;
414 } else {
415 return std::unexpected("the function `:" + name + "` is not in the ui/1 subset");
416 }
417 skipSpace();
418 }
419 if (atEnd() || peek() != '}') {
420 return std::unexpected(std::string{"options and attributes are not in the ui/1 subset"});
421 }
422 ++_pos;
423 return std::pair{std::move(*variable), function};
424 }
425
426 /// Reads a pattern up to the end of the text (simple) or up to `}}` (quoted).
427 [[nodiscard]] std::expected<std::vector<MessagePart>, std::string> parsePattern(bool quoted) {
428 std::vector<MessagePart> parts;
429 std::string text;
430 auto flush = [&] {
431 if (!text.empty()) {
432 parts.push_back(MessagePart{.text = std::move(text), .variable = {}, .function = {}});
433 text.clear();
434 }
435 };
436 while (!atEnd()) {
437 char const chr = peek();
438 if (quoted && _text.substr(_pos).starts_with("}}")) {
439 _pos += 2;
440 flush();
441 return parts;
442 }
443 if (chr == '\\') {
444 ++_pos;
445 if (atEnd() || (peek() != '\\' && peek() != '{' && peek() != '}' && peek() != '|')) {
446 return std::unexpected(std::string{"`\\` escapes only `\\`, `{`, `}` and `|`"});
447 }
448 text += peek();
449 ++_pos;
450 continue;
451 }
452 if (chr == '{') {
453 ++_pos;
454 auto body = readExpressionBody();
455 if (!body) {
456 return std::unexpected(body.error());
457 }
458 flush();
459 parts.push_back(MessagePart{.text = {}, .variable = std::move(body->first), .function = body->second});
460 continue;
461 }
462 if (chr == '}') {
463 return std::unexpected(std::string{"an unescaped `}` in text"});
464 }
465 text += chr;
466 ++_pos;
467 }
468 if (quoted) {
469 return std::unexpected(std::string{"a quoted pattern is not closed with `}}`"});
470 }
471 flush();
472 return parts;
473 }
474
475 [[nodiscard]] std::expected<std::string, std::string> readKey() {
476 if (peek() == '*') {
477 ++_pos;
478 return std::string{"*"};
479 }
480 if (peek() == '|') {
481 ++_pos;
482 std::string key;
483 while (!atEnd() && peek() != '|') {
484 if (peek() == '\\') {
485 ++_pos;
486 if (atEnd()) {
487 break;
488 }
489 }
490 key += peek();
491 ++_pos;
492 }
493 if (atEnd()) {
494 return std::unexpected(std::string{"a quoted key is not closed with `|`"});
495 }
496 ++_pos;
497 return key;
498 }
499 std::size_t const start = _pos;
500 if (peek() == '=') {
501 ++_pos;
502 }
503 while (!atEnd() && nameChar(peek(), false)) {
504 ++_pos;
505 }
506 if (_pos == start) {
507 return std::unexpected(std::string{"a variant key is expected"});
508 }
509 return std::string{_text.substr(start, _pos - start)};
510 }
511
512 [[nodiscard]] std::expected<MessagePattern, std::string> parseComplex(MessagePattern& message) {
513 for (;;) {
514 skipSpace();
515 if (_text.substr(_pos).starts_with(".input")) {
516 _pos += 6;
517 skipSpace();
518 if (atEnd() || peek() != '{') {
519 return std::unexpected(std::string{"`.input` takes a `{$name :function}`"});
520 }
521 ++_pos;
522 auto body = readExpressionBody();
523 if (!body) {
524 return std::unexpected(body.error());
525 }
526 message.inputs.push_back(std::move(*body));
527 continue;
528 }
529 if (_text.substr(_pos).starts_with(".match")) {
530 _pos += 6;
531 return parseMatch(message);
532 }
533 if (_text.substr(_pos).starts_with("{{")) {
534 _pos += 2;
535 auto pattern = parsePattern(true);
536 if (!pattern) {
537 return std::unexpected(pattern.error());
538 }
539 message.pattern = std::move(*pattern);
540 skipSpace();
541 if (!atEnd()) {
542 return std::unexpected(std::string{"text after the quoted pattern"});
543 }
544 return message;
545 }
546 return std::unexpected(std::string{"only `.input`, `.match` and a quoted pattern are in the ui/1 subset"});
547 }
548 }
549
550 [[nodiscard]] std::expected<MessagePattern, std::string> parseMatch(MessagePattern& message) {
551 if (auto refused = parseSelectors(message)) {
552 return std::unexpected(std::move(*refused));
553 }
554 bool catchAll = false;
555 for (skipSpace(); !atEnd(); skipSpace()) {
556 auto variant = parseVariant(message.selectors.size());
557 if (!variant) {
558 return std::unexpected(variant.error());
559 }
560 catchAll =
561 catchAll || std::ranges::all_of(variant->keys, [](std::string const& key) { return key == "*"; });
562 message.variants.push_back(std::move(*variant));
563 }
564 if (!catchAll) {
565 return std::unexpected(std::string{"`.match` needs a catch-all variant `*`"});
566 }
567 return message;
568 }
569
570 /// Reads the selectors of `.match`, each a variable declared with `:number` or `:string`.
571 [[nodiscard]] std::optional<std::string> parseSelectors(MessagePattern& message) {
572 for (skipSpace(); !atEnd() && peek() == '$'; skipSpace()) {
573 auto selector = readVariable();
574 if (!selector) {
575 return std::move(selector.error());
576 }
577 bool const annotated = std::ranges::any_of(message.inputs, [&selector](auto const& input) {
578 return input.first == *selector && input.second != MessageFunction::None;
579 });
580 if (!annotated) {
581 return "the selector `$" + *selector + "` needs an `.input` declaration with `:number` or `:string`";
582 }
583 message.selectors.push_back(std::move(*selector));
584 }
585 if (message.selectors.empty()) {
586 return std::string{"`.match` needs at least one selector"};
587 }
588 return std::nullopt;
589 }
590
591 /// Reads one variant: a key per selector, then its quoted pattern.
592 [[nodiscard]] std::expected<MessageVariant, std::string> parseVariant(std::size_t selectors) {
593 MessageVariant variant;
594 while (!atEnd() && !_text.substr(_pos).starts_with("{{")) {
595 auto key = readKey();
596 if (!key) {
597 return std::unexpected(key.error());
598 }
599 variant.keys.push_back(std::move(*key));
600 skipSpace();
601 }
602 if (atEnd()) {
603 return std::unexpected(std::string{"a variant needs a quoted pattern `{{...}}`"});
604 }
605 if (variant.keys.size() != selectors) {
606 return std::unexpected(std::string{"a variant has one key per selector"});
607 }
608 _pos += 2;
609 auto pattern = parsePattern(true);
610 if (!pattern) {
611 return std::unexpected(pattern.error());
612 }
613 variant.pattern = std::move(*pattern);
614 return variant;
615 }
616
617 std::string_view _text;
618 std::size_t _pos = 0;
619};
620
621} // namespace detail
622
623/// @brief Parses a message in the `ui/1` subset of MessageFormat 2.
624///
625/// The subset is: text with `\` escapes; placeholders `{$name}`, `{$name :number}` and `{$name :string}`; `.input`
626/// declarations of the same form; and `.match` over declared `:number` or `:string` variables, with variant keys
627/// that are plural categories, exact numbers (`0`, `=0`), exact strings, or `*`, and a required catch-all.
628/// Literal placeholders, options, markup and `.local` are outside it.
629/// @param text The message source.
630/// @return The parsed message, or the reason it is refused.
631[[nodiscard]] inline std::expected<MessagePattern, std::string> parseMessage(std::string_view text) {
633}
634
635/// @brief The locale a message is formatted for.
637 /// @brief The BCP 47 locale, which selects the plural rules.
638 std::string locale = "en";
639 /// @brief The locale's number formatting.
641};
642
643/// @brief A formatted message, and the variables it could not format.
645 /// @brief The text; a variable that could not be formatted shows as `{$name}`.
646 std::string text;
647 /// @brief One line per variable that was missing, `null`, or of the wrong kind for its function.
648 std::vector<std::string> errors;
649};
650
651namespace detail {
652
653/// @brief Reads an exact-number variant key: `-?[0-9]+(\.[0-9]+)?`, at most 18 digits.
654/// @param key The key, without a leading `=`.
655/// @return The exact value, or `std::nullopt` when the key is not such a number.
656[[nodiscard]] inline std::optional<math::Rational> parseExactKey(std::string_view key) {
657 bool const negative = key.starts_with('-');
658 if (negative) {
659 key.remove_prefix(1);
660 }
661 std::int64_t numerator = 0;
662 std::int64_t denominator = 1;
663 std::uint32_t digits = 0;
664 bool fraction = false;
665 for (char const chr : key) {
666 if (chr == '.' && !fraction && digits > 0) {
667 fraction = true;
668 continue;
669 }
670 if (chr < '0' || chr > '9' || ++digits > math::kMaxDecimalPlaces) {
671 return std::nullopt;
672 }
673 numerator = (numerator * 10) + (chr - '0');
674 if (fraction) {
675 denominator *= 10;
676 }
677 }
678 if (digits == 0 || key.ends_with('.')) {
679 return std::nullopt;
680 }
681 return math::Rational{math::Numerator{negative ? -numerator : numerator}, math::Denominator{denominator},
683}
684
685/// @brief Whether a variant key matches a selector's value.
686/// @param key The key.
687/// @param value The selector's value.
688/// @param function The selector's function.
689/// @param locale The locale for plural rules.
690/// @return 0 for an exact match, 1 for a plural-category match, 2 for `*`, `std::nullopt` for no match.
691[[nodiscard]] inline std::optional<int> matchKey(std::string_view key, Value const& value, MessageFunction function,
692 MessageLocale const& locale) {
693 if (key == "*") {
694 return 2;
695 }
696 if (function == MessageFunction::String) {
697 std::string const* const text = value.asString();
698 if (text != nullptr && *text == key) {
699 return 0;
700 }
701 return std::nullopt;
702 }
703 auto const number = numberOf(value);
704 if (!number) {
705 return std::nullopt;
706 }
707 std::string_view exact = key;
708 if (exact.starts_with('=')) {
709 exact.remove_prefix(1);
710 }
711 if (auto const wanted = parseExactKey(exact)) {
712 // An exact key compares by value with the number as shown, so `1` matches 1, 1.0 and 1.00.
713 math::Rational const shown = math::roundToDecimalPlaces(number->first, math::DecimalPlaces{number->second},
715 if (shown == *wanted) {
716 return 0;
717 }
718 return std::nullopt;
719 }
720 if (categoryName(pluralCategory(locale.locale, number->first, number->second)) == key) {
721 return 1;
722 }
723 return std::nullopt;
724}
725
726/// @brief Formats one pattern.
727/// @param pattern The parts.
728/// @param args The named arguments.
729/// @param functions The `.input` declarations, which annotate a bare placeholder of their variable.
730/// @param locale The locale.
731/// @param out The message being built.
732inline void formatPattern(std::vector<MessagePart> const& pattern, ValueRecord const& args,
733 std::vector<std::pair<std::string, MessageFunction>> const& functions,
734 MessageLocale const& locale, FormattedMessage& out) {
735 for (MessagePart const& part : pattern) {
736 if (part.variable.empty()) {
737 out.text += part.text;
738 continue;
739 }
740 MessageFunction function = part.function;
741 for (auto const& [name, declared] : functions) {
742 if (name == part.variable && function == MessageFunction::None) {
743 function = declared;
744 }
745 }
746 auto const found = args.find(part.variable);
747 Value const missing{};
748 Value const& value = found == args.end() ? missing : found->second;
749 if (std::string const* const text = value.asString(); text != nullptr && function != MessageFunction::Number) {
750 out.text += *text;
751 continue;
752 }
753 if (auto number = formatNumber(value, locale.numeric)) {
754 out.text += *number;
755 continue;
756 }
757 out.text += "{$" + part.variable + "}";
758 out.errors.push_back(found == args.end() || value.isNull()
759 ? "the argument `" + part.variable + "` is missing"
760 : "the argument `" + part.variable + "` is not a " +
761 (function == MessageFunction::Number ? "number" : "string or number"));
762 }
763}
764
765} // namespace detail
766
767/// @brief Formats a parsed message with named arguments.
768///
769/// A `.match` selects the variant whose keys match best, selector by selector: an exact key before a plural
770/// category before `*`.
771/// @param message The parsed message.
772/// @param args The named arguments.
773/// @param locale The locale.
774/// @return The text and the errors of the variables that could not be formatted.
775[[nodiscard]] inline FormattedMessage formatMessage(MessagePattern const& message, ValueRecord const& args,
776 MessageLocale const& locale) {
778 if (message.selectors.empty()) {
779 detail::formatPattern(message.pattern, args, message.inputs, locale, out);
780 return out;
781 }
782 MessageVariant const* best = nullptr;
783 std::vector<int> bestScore;
784 for (MessageVariant const& variant : message.variants) {
785 std::vector<int> score;
786 bool matches = true;
787 for (std::size_t index = 0; index < message.selectors.size() && matches; ++index) {
788 std::string const& selector = message.selectors.at(index);
790 for (auto const& [name, declared] : message.inputs) {
791 if (name == selector) {
792 function = declared;
793 }
794 }
795 auto const found = args.find(selector);
796 Value const missing{};
797 auto const rank = detail::matchKey(variant.keys.at(index), found == args.end() ? missing : found->second,
798 function, locale);
799 matches = rank.has_value();
800 score.push_back(rank.value_or(3));
801 }
802 if (matches && (best == nullptr || score < bestScore)) {
803 best = &variant;
804 bestScore = std::move(score);
805 }
806 }
807 // parseMessage requires a catch-all variant, so one always matches.
808 if (best != nullptr) {
809 detail::formatPattern(best->pattern, args, message.inputs, locale, out);
810 }
811 return out;
812}
813
814} // namespace morph::ui
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
The document's typed value (spec 5 §3): what every expression evaluates to.
Definition value.hpp:157
Quantity const * asQuantity() const noexcept
The quantity, if held.
Definition value.hpp:232
Decimal const * asDecimal() const noexcept
The decimal, if held.
Definition value.hpp:228
bool isNull() const noexcept
Whether the value is null.
Definition value.hpp:212
std::int64_t const * asInt() const noexcept
The int, if held.
Definition value.hpp:220
std::string const * asString() const noexcept
The string, if held.
Definition value.hpp:224
A recursive-descent reader over one message's text.
Definition message.hpp:332
MessageReader(std::string_view text)
Reads text.
Definition message.hpp:336
std::expected< MessagePattern, std::string > parse()
Parses the whole message.
Definition message.hpp:340
Locale numeric-entry normalisation for the control edge.
std::string formatCanonicalNumber(std::string_view canonicalText, NumericLocale const &loc)
Converts canonical (.-decimal) numeric text to locale-formatted display text, grouping the integer pa...
constexpr PluralCategory eastSlavicPlural(PluralOperands const &n) noexcept
ru, uk: one for 1 outside 11, few for 2..4 outside 12..14, many for other integers.
Definition message.hpp:190
PluralCategory
A CLDR plural category.
Definition message.hpp:30
constexpr std::string_view categoryName(PluralCategory category)
The keyword of a plural category, as a .match key spells it.
Definition message.hpp:42
std::optional< math::Rational > parseExactKey(std::string_view key)
Reads an exact-number variant key: -?[0-9]+(\.[0-9]+)?, at most 18 digits.
Definition message.hpp:656
std::optional< std::string > formatNumber(Value const &value, render::NumericLocale const &numeric)
Formats a number for display in a locale: at its own places, rounded half to even.
Definition message.hpp:97
PluralCategory(*)(PluralOperands const &) noexcept PluralRule
A language's plural rule.
Definition message.hpp:239
constexpr PluralCategory polishPlural(PluralOperands const &n) noexcept
pl: one, few for 2..4 outside 12..14, many for other integers, other for fractions.
Definition message.hpp:174
FormattedMessage formatMessage(MessagePattern const &message, ValueRecord const &args, MessageLocale const &locale)
Formats a parsed message with named arguments.
Definition message.hpp:775
std::optional< std::pair< math::Rational, std::uint32_t > > numberOf(Value const &value)
The places a number is shown with: an int has none, a decimal or quantity its declared places.
Definition message.hpp:78
constexpr bool wholeMillion(PluralOperands const &n) noexcept
CLDR's many of the Romance languages: an integer that is a non-zero multiple of a million.
Definition message.hpp:130
constexpr PluralCategory arabicPlural(PluralOperands const &n) noexcept
ar: zero, one, two, few for 3..10 and many for 11..99 (both modulo 100).
Definition message.hpp:219
constexpr PluralCategory spanishPlural(PluralOperands const &n) noexcept
es: one is n = 1, and many a whole million.
Definition message.hpp:154
constexpr PluralCategory italianPlural(PluralOperands const &n) noexcept
it: as English, and many for a whole million.
Definition message.hpp:144
constexpr std::array< std::pair< std::string_view, PluralRule >, 13 > kPluralRules
The languages whose rules are carried, by language subtag.
Definition message.hpp:242
constexpr bool between(std::uint64_t operand, std::uint64_t low, std::uint64_t high) noexcept
Whether an operand lies in a CLDR range low..high.
Definition message.hpp:123
PluralCategory pluralCategory(std::string_view locale, math::Rational const &value, std::uint32_t places)
The CLDR plural category of a number in a locale.
Definition message.hpp:270
MessageFunction
The formatting function a placeholder or a declaration names.
Definition message.hpp:293
@ String
:string: the text, and selection by exact match.
Definition message.hpp:296
@ None
No annotation: a string as it is, a number as :number formats it.
Definition message.hpp:294
@ Number
:number: locale number formatting, and plural selection.
Definition message.hpp:295
std::optional< int > matchKey(std::string_view key, Value const &value, MessageFunction function, MessageLocale const &locale)
Whether a variant key matches a selector's value.
Definition message.hpp:691
void formatPattern(std::vector< MessagePart > const &pattern, ValueRecord const &args, std::vector< std::pair< std::string, MessageFunction > > const &functions, MessageLocale const &locale, FormattedMessage &out)
Formats one pattern.
Definition message.hpp:732
constexpr PluralCategory czechPlural(PluralOperands const &n) noexcept
cs: one, few for 2..4, many for fractions.
Definition message.hpp:206
std::expected< MessagePattern, std::string > parseMessage(std::string_view text)
Parses a message in the ui/1 subset of MessageFormat 2.
Definition message.hpp:631
std::string decimalText(math::Rational const &value, std::uint32_t places)
The decimal text of an exact value at a number of places: rounded half to even, then written with exa...
Definition message.hpp:54
constexpr PluralCategory germanicPlural(PluralOperands const &n) noexcept
en, de, nl, sv: one is i = 1 and v = 0.
Definition message.hpp:137
constexpr PluralCategory frenchPlural(PluralOperands const &n) noexcept
fr, pt: one is i = 0..1, and many a whole million.
Definition message.hpp:164
Exact rational arithmetic for morph model/action values.
constexpr Rational roundToDecimalPlaces(const Rational &value, DecimalPlaces places, RoundingMode mode=RoundingMode::HalfAwayFromZero) noexcept
Rounds to a fixed number of decimal places exactly, without leaving the Rational domain – the operati...
@ HalfEven
Ties go to the neighbour whose last kept digit is even: 1.25 -> 1.2, 1.35 -> 1.4.
Definition rational.hpp:192
constexpr std::uint32_t kMaxDecimalPlaces
Largest decimal precision the type supports: 10^18 is the greatest power of ten that fits in std::int...
Definition rational.hpp:167
Strong type for a decimal-precision count.
Definition rational.hpp:121
Strong type for a Rational denominator.
Definition rational.hpp:154
Strong type for a Rational numerator.
Definition rational.hpp:140
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
std::int64_t numerator
Signed numerator. Carries the sign of the rational value.
Definition rational.hpp:377
std::int64_t denominator
Strictly positive denominator. Never zero, never negative.
Definition rational.hpp:380
The locale facts both control-edge conversions need, in one designated-initialisable aggregate.
An exact decimal: a math::Rational whose DecimalPlaces are the value's declared places.
Definition value.hpp:95
A formatted message, and the variables it could not format.
Definition message.hpp:644
std::vector< std::string > errors
One line per variable that was missing, null, or of the wrong kind for its function.
Definition message.hpp:648
std::string text
The text; a variable that could not be formatted shows as {$name}.
Definition message.hpp:646
The locale a message is formatted for.
Definition message.hpp:636
std::string locale
The BCP 47 locale, which selects the plural rules.
Definition message.hpp:638
render::NumericLocale numeric
The locale's number formatting.
Definition message.hpp:640
One part of a pattern: literal text, or a placeholder of a variable.
Definition message.hpp:300
std::string text
The literal text; empty for a placeholder.
Definition message.hpp:302
MessageFunction function
The placeholder's annotation.
Definition message.hpp:306
std::string variable
The variable a placeholder reads, without $; empty for text.
Definition message.hpp:304
A parsed message in the ui/1 subset of MessageFormat 2.
Definition message.hpp:318
std::vector< MessagePart > pattern
The pattern of a message without .match.
Definition message.hpp:326
std::vector< std::string > selectors
The .match selectors, as variable names; empty for a message without .match.
Definition message.hpp:322
std::vector< MessageVariant > variants
The variants of a .match.
Definition message.hpp:324
std::vector< std::pair< std::string, MessageFunction > > inputs
The .input declarations: each variable with its function.
Definition message.hpp:320
One variant of a .match: a key per selector (* is the catch-all) and its pattern.
Definition message.hpp:310
std::vector< MessagePart > pattern
The pattern shown when the variant is selected.
Definition message.hpp:314
std::vector< std::string > keys
The keys, one per selector.
Definition message.hpp:312
An exact decimal with a unit: the id of a unit of the server's unit systems (x-unitAlternatives).
Definition value.hpp:109
CLDR's plural operands of a number as it is shown.
Definition message.hpp:109
std::uint64_t integer
i: the integer digits of the absolute value.
Definition message.hpp:111
bool visibleFraction
v != 0: whether the number shows fraction digits.
Definition message.hpp:115
bool whole
Whether n has no fraction.
Definition message.hpp:113
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
std::map< std::string, Value, std::less<> > ValueRecord
A record value: field name to value, ordered by name.
Definition value.hpp:129
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194