morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
text.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/engine/detail/text.hpp
6/// @brief Exact text helpers the forms engine shares: JSON quoting and reading, canonical
7/// decimal comparison and digit arithmetic, decimal rendering of a `Rational`, and
8/// ISO-8601 wall-clock conversion.
9///
10/// Specified in `docs/spec/forms/engine.md`.
11
12#include <algorithm>
13#include <array>
14#include <charconv>
15#include <chrono>
16#include <cmath>
17#include <compare>
18#include <cstddef>
19#include <cstdint>
20#include <format>
21#include <glaze/glaze.hpp>
22#include <memory>
23#include <optional>
24#include <string>
25#include <string_view>
26#include <system_error>
27#include <utility>
28#include <vector>
29
30#include "../../../ui/value.hpp"
32
33/// @brief Implementation details of the forms engine (`include/morph/forms/engine/`).
34namespace morph::forms::detail::engine {
35
36/// @brief The JSON DOM the engine reads: integers keep every digit, as `uint64_t` or `int64_t`.
37using Json = glz::generic_u64;
38
39/// @brief Strips ASCII whitespace from both ends.
40/// @param text The text.
41/// @return A view of @p text without leading or trailing spaces, tabs or line breaks.
42[[nodiscard]] inline std::string_view trim(std::string_view text) noexcept {
43 constexpr std::string_view kSpace = " \t\r\n\f\v";
44 auto const first = text.find_first_not_of(kSpace);
45 if (first == std::string_view::npos) {
46 return {};
47 }
48 auto const last = text.find_last_not_of(kSpace);
49 return text.substr(first, last - first + 1);
50}
51
52/// @brief Quotes @p text as a JSON string exactly as `JSON.stringify` does: `"` and `\` escaped,
53/// the five short control escapes, any other control character as `\u00xx`, everything
54/// else verbatim. The body bytes the engine produces depend on this spelling.
55/// @param text UTF-8 text.
56/// @return The quoted literal.
57[[nodiscard]] inline std::string quote(std::string_view text) {
58 constexpr std::string_view kHex = "0123456789abcdef";
59 std::string out;
60 out.reserve(text.size() + 2);
61 out += '"';
62 for (char const chr : text) {
63 auto const code = static_cast<unsigned char>(chr);
64 switch (chr) {
65 case '"':
66 out += "\\\"";
67 break;
68 case '\\':
69 out += "\\\\";
70 break;
71 case '\b':
72 out += "\\b";
73 break;
74 case '\f':
75 out += "\\f";
76 break;
77 case '\n':
78 out += "\\n";
79 break;
80 case '\r':
81 out += "\\r";
82 break;
83 case '\t':
84 out += "\\t";
85 break;
86 default:
87 if (code < 0x20U) {
88 out += "\\u00";
89 out += kHex.at(code >> 4U);
90 out += kHex.at(code & 0x0FU);
91 } else {
92 out += chr;
93 }
94 break;
95 }
96 }
97 out += '"';
98 return out;
99}
100
101/// @brief Parses JSON text into the u64-mode DOM, refusing text nested deeper than `ui::kMaxJsonDepth`: a schema
102/// or a reply is served text, and every pass over the tree recurses.
103/// @param text JSON text.
104/// @return The DOM, or `std::nullopt` when @p text is not JSON or nests too deeply.
105[[nodiscard]] inline std::optional<Json> parseJson(std::string_view text) {
106 auto parsed = ui::parseJson(text);
107 if (!parsed) {
108 return std::nullopt;
109 }
110 return *std::move(parsed);
111}
112
113/// @brief Writes a DOM value as compact JSON; integers keep every digit.
114/// @param value The value.
115/// @return Its JSON text.
116[[nodiscard]] inline std::string toJson(Json const& value) { return glz::write_json(value).value_or(std::string{}); }
117
118/// @brief A DOM node holding an empty JSON object.
119/// @return `{}`.
120[[nodiscard]] inline Json emptyObject() {
121 Json node{};
122 node.data.emplace<Json::object_t>();
123 return node;
124}
125
126/// @brief The member @p key of an object node, without inserting anything.
127/// @param node The node; a non-object has no members.
128/// @param key The member name.
129/// @return The member, or null.
130[[nodiscard]] inline Json const* member(Json const& node, std::string_view key) {
131 auto const* const object = node.get_if<Json::object_t>();
132 if (object == nullptr) {
133 return nullptr;
134 }
135 const auto* const found = object->find(key);
136 return found == object->end() ? nullptr : &found->second;
137}
138
139/// @brief The string member @p key of an object node.
140/// @param node The node.
141/// @param key The member name.
142/// @return The string, or null when absent or not a string.
143[[nodiscard]] inline std::string const* stringAt(Json const& node, std::string_view key) {
144 Json const* const found = member(node, key);
145 return found == nullptr ? nullptr : found->get_if<std::string>();
146}
147
148/// @brief A JSON number as canonical decimal text, `-?[0-9]+(\.[0-9]+)?`.
149///
150/// Integers keep every digit; a double is written in fixed notation with the shortest digits
151/// that read back as the same double, so `1e-7` is `"0.0000001"` and no text has an exponent.
152/// @param value The value.
153/// @return The text, or `std::nullopt` when @p value is not a finite number.
154[[nodiscard]] inline std::optional<std::string> numberText(Json const& value) {
155 if (auto const* const unsignedValue = value.get_if<std::uint64_t>()) {
156 return std::to_string(*unsignedValue);
157 }
158 if (auto const* const signedValue = value.get_if<std::int64_t>()) {
159 return std::to_string(*signedValue);
160 }
161 if (auto const* const doubleValue = value.get_if<double>()) {
162 if (!std::isfinite(*doubleValue)) {
163 return std::nullopt;
164 }
165 // 309 integer digits for DBL_MAX, a sign, and the longest shortest fraction.
166 std::array<char, 400> buffer{};
167 auto const result = std::to_chars(std::to_address(buffer.begin()), std::to_address(buffer.end()), *doubleValue,
168 std::chars_format::fixed);
169 if (result.ec != std::errc{}) {
170 return std::nullopt;
171 }
172 std::string text{buffer.data(), result.ptr};
173 if (text == "-0") {
174 text = "0";
175 }
176 return text;
177 }
178 return std::nullopt;
179}
180
181/// @brief A scalar as display text: a string as itself, a number as `numberText`, a boolean
182/// as `true`/`false`, null as empty, anything else as compact JSON.
183/// @param value The value.
184/// @return The text.
185[[nodiscard]] inline std::string displayText(Json const& value) {
186 if (auto const* const text = value.get_if<std::string>()) {
187 return *text;
188 }
189 if (auto number = numberText(value)) {
190 return *std::move(number);
191 }
192 if (auto const* const flag = value.get_if<bool>()) {
193 return *flag ? "true" : "false";
194 }
195 if (value.is_null()) {
196 return {};
197 }
198 return toJson(value);
199}
200
201/// @brief Whether @p text is `-?[0-9]+(\.[0-9]+)?`.
202/// @param text The text.
203/// @return `true` for canonical decimal text.
204[[nodiscard]] inline bool isCanonicalDecimal(std::string_view text) noexcept {
205 if (text.starts_with('-')) {
206 text.remove_prefix(1);
207 }
208 auto const dot = text.find('.');
209 auto const digits = [](std::string_view part) {
210 return !part.empty() && part.find_first_not_of("0123456789") == std::string_view::npos;
211 };
212 if (dot == std::string_view::npos) {
213 return digits(text);
214 }
215 return digits(text.substr(0, dot)) && digits(text.substr(dot + 1));
216}
217
218/// @brief Whether @p text is `-?[0-9]+`.
219/// @param text The text.
220/// @return `true` for canonical integer text.
221[[nodiscard]] inline bool isCanonicalInteger(std::string_view text) noexcept {
222 return isCanonicalDecimal(text) && !text.contains('.');
223}
224
225/// @brief Drops a leading run of zeros that JSON forbids: `"007.50"` → `"7.50"`, `"-00"` → `"-0"`.
226/// @param canonical Canonical decimal text.
227/// @return The text with at most one leading zero before the point.
228[[nodiscard]] inline std::string stripLeadingZeros(std::string_view canonical) {
229 std::string out;
230 if (canonical.starts_with('-')) {
231 out += '-';
232 canonical.remove_prefix(1);
233 }
234 while (canonical.size() > 1 && canonical.front() == '0' && canonical.at(1) != '.') {
235 canonical.remove_prefix(1);
236 }
237 out += canonical;
238 return out;
239}
240
241/// @brief The ordering the other way round: `less` for `greater`, `greater` for `less`, `equal` for `equal`.
242/// @param order The ordering.
243/// @return Its reverse.
244[[nodiscard]] constexpr std::strong_ordering reversed(std::strong_ordering order) noexcept {
245 if (std::is_lt(order)) {
246 return std::strong_ordering::greater;
247 }
248 if (std::is_gt(order)) {
249 return std::strong_ordering::less;
250 }
251 return std::strong_ordering::equal;
252}
253
254/// @brief Orders two canonical decimals exactly, at any magnitude; `-0` equals `0`.
255/// @param lhs Canonical decimal text (leading zeros allowed).
256/// @param rhs Canonical decimal text (leading zeros allowed).
257/// @return The ordering of the two values.
258[[nodiscard]] inline std::strong_ordering compareDecimal(std::string_view lhs, std::string_view rhs) noexcept {
259 struct Parts {
260 bool negative = false;
261 std::string_view whole;
262 std::string_view fraction;
263 };
264 auto const split = [](std::string_view text) {
265 Parts parts{};
266 parts.negative = text.starts_with('-');
267 if (parts.negative) {
268 text.remove_prefix(1);
269 }
270 auto const dot = text.find('.');
271 parts.whole = text.substr(0, dot);
272 parts.fraction = dot == std::string_view::npos ? std::string_view{} : text.substr(dot + 1);
273 while (parts.whole.size() > 1 && parts.whole.front() == '0') {
274 parts.whole.remove_prefix(1);
275 }
276 while (!parts.fraction.empty() && parts.fraction.back() == '0') {
277 parts.fraction.remove_suffix(1);
278 }
279 if ((parts.whole.empty() || parts.whole == "0") && parts.fraction.empty()) {
280 parts.negative = false;
281 }
282 return parts;
283 };
284 auto const magnitude = [](Parts const& left, Parts const& right) {
285 if (left.whole.size() != right.whole.size()) {
286 return left.whole.size() <=> right.whole.size();
287 }
288 if (auto const order = left.whole <=> right.whole; std::is_neq(order)) {
289 return order;
290 }
291 return left.fraction <=> right.fraction;
292 };
293 Parts const left = split(lhs);
294 Parts const right = split(rhs);
295 if (left.negative != right.negative) {
296 return left.negative ? std::strong_ordering::less : std::strong_ordering::greater;
297 }
298 auto const order = magnitude(left, right);
299 if (!left.negative) {
300 return order;
301 }
302 return reversed(order);
303}
304
305/// @brief Multiplies two non-negative digit strings exactly.
306/// @param lhs Decimal digits.
307/// @param rhs Decimal digits.
308/// @return The product's digits, without leading zeros.
309[[nodiscard]] inline std::string multiplyDigits(std::string_view lhs, std::string_view rhs) {
310 std::vector<std::uint32_t> product(lhs.size() + rhs.size(), 0U);
311 for (std::size_t i = lhs.size(); i-- > 0;) {
312 for (std::size_t j = rhs.size(); j-- > 0;) {
313 auto const left = static_cast<std::uint32_t>(lhs.at(i) - '0');
314 auto const right = static_cast<std::uint32_t>(rhs.at(j) - '0');
315 std::uint32_t& low = product.at(i + j + 1);
316 std::uint32_t const sum = (left * right) + low;
317 low = sum % 10U;
318 product.at(i + j) += sum / 10U;
319 }
320 }
321 std::string out;
322 for (std::uint32_t const digit : product) {
323 if (out.empty() && digit == 0U) {
324 continue;
325 }
326 out += static_cast<char>('0' + digit);
327 }
328 return out.empty() ? std::string{"0"} : out;
329}
330
331/// @brief Orders an exact rational against a canonical decimal, exactly, at any magnitude.
332///
333/// Cross-multiplies on digits: `|num| * 10^places` against `|digits| * den`, so neither side is
334/// ever narrowed to a `Rational` or a `double`.
335/// @param value The rational.
336/// @param decimal Canonical decimal text.
337/// @return The ordering of @p value against @p decimal.
338[[nodiscard]] inline std::strong_ordering compareRationalToDecimal(math::Rational const& value,
339 std::string_view decimal) {
340 bool const decimalNegative = decimal.starts_with('-');
341 std::string_view const magnitude = decimalNegative ? decimal.substr(1) : decimal;
342 auto const dot = magnitude.find('.');
343 std::string digits{magnitude.substr(0, dot)};
344 std::size_t places = 0;
345 if (dot != std::string_view::npos) {
346 digits += magnitude.substr(dot + 1);
347 places = magnitude.size() - dot - 1;
348 }
349 bool const decimalZero = std::ranges::all_of(digits, [](char chr) { return chr == '0'; });
350 auto const signOf = [](bool zero, bool negative) {
351 if (zero) {
352 return 0;
353 }
354 return negative ? -1 : 1;
355 };
356 int const decimalSign = signOf(decimalZero, decimalNegative);
357 int const valueSign = signOf(value.numerator == 0, value.numerator < 0);
358 if (valueSign != decimalSign) {
359 return valueSign <=> decimalSign;
360 }
361 if (valueSign == 0) {
362 return std::strong_ordering::equal;
363 }
364 std::string const scaledValue = std::to_string(math::detail::absU64(value.numerator)) + std::string(places, '0');
365 std::string const scaledDecimal = multiplyDigits(digits, std::to_string(value.denominator));
366 auto const order = compareDecimal(scaledValue, scaledDecimal);
367 return valueSign > 0 ? order : reversed(order);
368}
369
370/// @brief Renders @p value as canonical decimal text, rounded half away from zero to @p places.
371/// @param value The value.
372/// @param places Fraction digits; clamped to `math::kMaxDecimalPlaces`.
373/// @return E.g. `"-0.33"` for `-1/3` at two places; a value that rounds to zero has no sign.
374[[nodiscard]] inline std::string decimalText(math::Rational const& value, std::uint32_t places) {
375 std::uint32_t const wanted = std::min(places, math::kMaxDecimalPlaces);
376 math::Rational const rounded =
377 math::roundToDecimalPlaces(value, math::DecimalPlaces{wanted}, math::RoundingMode::HalfAwayFromZero);
378 std::int64_t const scale = math::detail::powerOfTen(wanted);
379 std::string digits = multiplyDigits(std::to_string(math::detail::absU64(rounded.numerator)),
380 std::to_string(scale / rounded.denominator));
381 if (digits.size() <= wanted) {
382 digits.insert(0, wanted + 1 - digits.size(), '0');
383 }
384 std::string out = rounded.numerator < 0 ? "-" : "";
385 out += std::string_view{digits}.substr(0, digits.size() - wanted);
386 if (wanted > 0) {
387 out += '.';
388 out += std::string_view{digits}.substr(digits.size() - wanted);
389 }
390 return out;
391}
392
393/// @brief Reads `count` ASCII digits at @p offset of @p text.
394/// @param text The text.
395/// @param offset Where the digits start.
396/// @param count How many digits.
397/// @return The value, or `std::nullopt` when a character there is not a digit.
398[[nodiscard]] inline std::optional<int> digitsAt(std::string_view text, std::size_t offset, std::size_t count) {
399 if (offset + count > text.size()) {
400 return std::nullopt;
401 }
402 int value = 0;
403 for (std::size_t i = offset; i < offset + count; ++i) {
404 char const chr = text.at(i);
405 if (chr < '0' || chr > '9') {
406 return std::nullopt;
407 }
408 value = (value * 10) + (chr - '0');
409 }
410 return value;
411}
412
413/// @brief Reads `YYYY-MM-DDTHH:MM[:SS]` as a calendar instant, validating every component.
414///
415/// The result is the reading as if it were UTC; the caller shifts it by its display zone.
416/// @param text The wall-clock text.
417/// @return The instant, or `std::nullopt` for malformed or impossible text.
418[[nodiscard]] inline std::optional<std::chrono::sys_seconds> parseWallClock(std::string_view text) {
419 if (text.size() != 16 && text.size() != 19) {
420 return std::nullopt;
421 }
422 if (text.at(4) != '-' || text.at(7) != '-' || text.at(10) != 'T' || text.at(13) != ':' ||
423 (text.size() == 19 && text.at(16) != ':')) {
424 return std::nullopt;
425 }
426 auto const year = digitsAt(text, 0, 4);
427 auto const month = digitsAt(text, 5, 2);
428 auto const day = digitsAt(text, 8, 2);
429 auto const hour = digitsAt(text, 11, 2);
430 auto const minute = digitsAt(text, 14, 2);
431 auto const second = text.size() == 19 ? digitsAt(text, 17, 2) : std::optional<int>{0};
432 if (!year || !month || !day || !hour || !minute || !second || *hour > 23 || *minute > 59 || *second > 59) {
433 return std::nullopt;
434 }
435 std::chrono::year_month_day const date{std::chrono::year{*year}, std::chrono::month{static_cast<unsigned>(*month)},
436 std::chrono::day{static_cast<unsigned>(*day)}};
437 if (!date.ok()) {
438 return std::nullopt;
439 }
440 return std::chrono::sys_days{date} + std::chrono::hours{*hour} + std::chrono::minutes{*minute} +
441 std::chrono::seconds{*second};
442}
443
444/// @brief Reads an ISO-8601 instant: `YYYY-MM-DDTHH:MM[:SS[.fraction]][Z|±HH:MM|±HHMM]`.
445///
446/// No zone designator reads as UTC, which is what a `Timestamp` serialises to; a fraction is
447/// dropped, since every engine display is to the second.
448/// @param text The instant text.
449/// @return The UTC instant, or `std::nullopt` when malformed.
450[[nodiscard]] inline std::optional<std::chrono::sys_seconds> parseInstant(std::string_view text) {
451 std::size_t clockEnd = 19;
452 if (text.size() < 19 || text.at(16) != ':') {
453 clockEnd = 16;
454 }
455 auto local = parseWallClock(text.substr(0, clockEnd));
456 if (!local) {
457 return std::nullopt;
458 }
459 std::string_view rest = text.substr(clockEnd);
460 if (rest.starts_with('.')) {
461 rest.remove_prefix(1);
462 while (!rest.empty() && rest.front() >= '0' && rest.front() <= '9') {
463 rest.remove_prefix(1);
464 }
465 }
466 if (rest.empty() || rest == "Z") {
467 return local;
468 }
469 if (rest.front() != '+' && rest.front() != '-') {
470 return std::nullopt;
471 }
472 int const sign = rest.front() == '-' ? -1 : 1;
473 rest.remove_prefix(1);
474 std::string compact{rest};
475 if (compact.size() == 5 && compact.at(2) == ':') {
476 compact.erase(2, 1);
477 }
478 auto const hours = digitsAt(compact, 0, 2);
479 auto const minutes = digitsAt(compact, 2, 2);
480 if (compact.size() != 4 || !hours || !minutes) {
481 return std::nullopt;
482 }
483 return *local - (sign * (std::chrono::hours{*hours} + std::chrono::minutes{*minutes}));
484}
485
486/// @brief Writes an instant as `YYYY-MM-DDTHH:MM:SS`.
487/// @param instant The instant.
488/// @return The wall-clock text.
489[[nodiscard]] inline std::string formatWallClock(std::chrono::sys_seconds instant) {
490 auto const days = std::chrono::floor<std::chrono::days>(instant);
491 std::chrono::year_month_day const date{days};
492 std::chrono::hh_mm_ss const clock{instant - days};
493 return std::format("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", static_cast<int>(date.year()),
494 static_cast<unsigned>(date.month()), static_cast<unsigned>(date.day()), clock.hours().count(),
495 clock.minutes().count(), clock.seconds().count());
496}
497
498/// @brief Writes an instant as ISO-8601 UTC, `YYYY-MM-DDTHH:MM:SSZ`.
499/// @param instant The instant.
500/// @return The text.
501[[nodiscard]] inline std::string formatUtc(std::chrono::sys_seconds instant) { return formatWallClock(instant) + "Z"; }
502
503/// @brief Whether @p text is a valid calendar date `YYYY-MM-DD`.
504/// @param text The text.
505/// @return `true` for a well-formed, existing date.
506[[nodiscard]] inline bool isIsoDate(std::string_view text) {
507 if (text.size() != 10 || text.at(4) != '-' || text.at(7) != '-') {
508 return false;
509 }
510 auto const year = digitsAt(text, 0, 4);
511 auto const month = digitsAt(text, 5, 2);
512 auto const day = digitsAt(text, 8, 2);
513 if (!year || !month || !day) {
514 return false;
515 }
516 return std::chrono::year_month_day{std::chrono::year{*year}, std::chrono::month{static_cast<unsigned>(*month)},
517 std::chrono::day{static_cast<unsigned>(*day)}}
518 .ok();
519}
520
521} // namespace morph::forms::detail::engine
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
AnyHandle sum(AnyHandle const &list)
sum of a list of numbers.
Definition author.hpp:1005
Handle< std::int64_t > count(AnyHandle const &list)
count: a list's length.
Definition author.hpp:1000
Handle< Member > at(Member Class::*member) const
A field of the value, by member pointer: a renamed field is a compile error.
Definition author.hpp:703
@ Parts
A list of such records.
Definition document.hpp:612
Exact rational arithmetic for morph model/action values.
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...