|
2 | 2 | // Copyright (c) Robert Vokac and contributors |
3 | 3 | // Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors) |
4 | 4 | #pragma once |
| 5 | +#include <algorithm> |
5 | 6 | #include <array> |
6 | 7 | #include <charconv> |
7 | 8 | #include <cstddef> |
@@ -110,4 +111,265 @@ template <class T> |
110 | 111 | return std::string(buffer.data(), ptr); |
111 | 112 | } |
112 | 113 |
|
| 114 | +/** |
| 115 | + * @brief Whether @p format is a .NET **standard** numeric format string. |
| 116 | + * |
| 117 | + * .NET reads a format as standard only when it is a single alphabetic character |
| 118 | + * optionally followed by a precision of decimal digits (`"F2"`, `"G"`, `"E3"`). |
| 119 | + * Everything else -- `"0.000"`, `"#,##0.0"`, `"00"` -- is a **custom** numeric |
| 120 | + * format string, a completely separate grammar. |
| 121 | + * |
| 122 | + * @param format The format string; must not be empty. |
| 123 | + * @return True when @p format has the standard shape. |
| 124 | + */ |
| 125 | +[[nodiscard]] inline bool isStandardNumericFormat(const std::string& format) { |
| 126 | + if (format.empty()) return false; |
| 127 | + const unsigned char first = static_cast<unsigned char>(format[0]); |
| 128 | + if (!((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z'))) return false; |
| 129 | + for (std::size_t i = 1; i < format.size(); ++i) { |
| 130 | + const unsigned char c = static_cast<unsigned char>(format[i]); |
| 131 | + if (c < '0' || c > '9') return false; |
| 132 | + } |
| 133 | + return true; |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * @brief The subset of .NET's custom numeric format grammar this build implements. |
| 138 | + * |
| 139 | + * Implemented: the digit placeholders `0` (always emitted) and `#` (emitted only |
| 140 | + * when significant), the decimal point `.`, and `,` used as a group separator |
| 141 | + * between integer placeholders. Any other character is copied through as a |
| 142 | + * literal, which is what .NET does with an unrecognised character. |
| 143 | + * |
| 144 | + * Not implemented, and refused rather than silently mis-emitted: the `;` section |
| 145 | + * separator, the `%` and `‰` scaling specifiers, the custom `E0` exponent forms, |
| 146 | + * and `\` / quote escaping. |
| 147 | + */ |
| 148 | +struct CustomNumericFormat { |
| 149 | + std::size_t minimumIntegerDigits = 0; ///< Count of `0` placeholders left of the point. |
| 150 | + std::size_t maximumDecimals = 0; ///< Count of placeholders right of the point. |
| 151 | + std::size_t minimumDecimals = 0; ///< Count of `0` placeholders right of the point. |
| 152 | + bool hasDecimalPoint = false; ///< Whether the format contains a `.`. |
| 153 | + bool groupSeparators = false; ///< Whether a `,` sits between integer placeholders. |
| 154 | +}; |
| 155 | + |
| 156 | +/** |
| 157 | + * @brief Parses a custom numeric format string. |
| 158 | + * |
| 159 | + * @param format The custom format, e.g. `"0.000"`. |
| 160 | + * @return The parsed shape. |
| 161 | + * @throws System::FormatException via @p onUnsupported for a construct this build |
| 162 | + * does not implement. |
| 163 | + */ |
| 164 | +template <class OnUnsupported> |
| 165 | +[[nodiscard]] inline CustomNumericFormat parseCustomNumericFormat(const std::string& format, |
| 166 | + OnUnsupported onUnsupported) { |
| 167 | + CustomNumericFormat shape; |
| 168 | + bool afterPoint = false; |
| 169 | + bool sawIntegerPlaceholder = false; |
| 170 | + for (std::size_t i = 0; i < format.size(); ++i) { |
| 171 | + const char c = format[i]; |
| 172 | + if (c == ';' || c == '%' || c == '\\' || c == '\'' || c == '"' || |
| 173 | + ((c == 'E' || c == 'e') && i + 1 < format.size() && |
| 174 | + (format[i + 1] == '0' || format[i + 1] == '+' || format[i + 1] == '-'))) { |
| 175 | + onUnsupported(); |
| 176 | + } |
| 177 | + if (c == '.') { |
| 178 | + // Only the first point is the decimal separator; later ones are literals. |
| 179 | + if (!shape.hasDecimalPoint) shape.hasDecimalPoint = true; |
| 180 | + afterPoint = true; |
| 181 | + continue; |
| 182 | + } |
| 183 | + if (c == ',') { |
| 184 | + // A comma only groups when it sits between integer digit placeholders. |
| 185 | + if (!afterPoint && sawIntegerPlaceholder) shape.groupSeparators = true; |
| 186 | + continue; |
| 187 | + } |
| 188 | + if (c == '0' || c == '#') { |
| 189 | + if (afterPoint) { |
| 190 | + ++shape.maximumDecimals; |
| 191 | + if (c == '0') shape.minimumDecimals = shape.maximumDecimals; |
| 192 | + } else { |
| 193 | + sawIntegerPlaceholder = true; |
| 194 | + if (c == '0') ++shape.minimumIntegerDigits; |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + return shape; |
| 199 | +} |
| 200 | + |
| 201 | +/** |
| 202 | + * @brief Splits a decimal text into its sign, integer digits and fraction digits. |
| 203 | + * |
| 204 | + * Accepts the shortest round-trippable text `to_chars` produces, including the |
| 205 | + * exponential forms (`1e-07`), which are expanded so the caller only ever sees |
| 206 | + * plain digit strings. |
| 207 | + * |
| 208 | + * @param text A decimal or exponential number, e.g. `"-0.5"` or `"1e-07"`. |
| 209 | + * @param negative Set to true when @p text is negative. |
| 210 | + * @param integerDigits Receives the integer digits, without leading zeros. |
| 211 | + * @param fractionDigits Receives the fraction digits. |
| 212 | + */ |
| 213 | +inline void splitDecimalText(const std::string& text, bool& negative, |
| 214 | + std::string& integerDigits, std::string& fractionDigits) { |
| 215 | + negative = false; |
| 216 | + std::size_t i = 0; |
| 217 | + if (i < text.size() && (text[i] == '-' || text[i] == '+')) { |
| 218 | + negative = text[i] == '-'; |
| 219 | + ++i; |
| 220 | + } |
| 221 | + std::string digits; |
| 222 | + int pointPosition = -1; |
| 223 | + int exponent = 0; |
| 224 | + for (; i < text.size(); ++i) { |
| 225 | + const char c = text[i]; |
| 226 | + if (c == '.') { pointPosition = static_cast<int>(digits.size()); continue; } |
| 227 | + if (c == 'e' || c == 'E') { exponent = std::stoi(text.substr(i + 1)); break; } |
| 228 | + digits.push_back(c); |
| 229 | + } |
| 230 | + if (pointPosition < 0) pointPosition = static_cast<int>(digits.size()); |
| 231 | + pointPosition += exponent; |
| 232 | + while (pointPosition < 0) { digits.insert(digits.begin(), '0'); ++pointPosition; } |
| 233 | + while (static_cast<std::size_t>(pointPosition) > digits.size()) digits.push_back('0'); |
| 234 | + integerDigits = digits.substr(0, static_cast<std::size_t>(pointPosition)); |
| 235 | + fractionDigits = digits.substr(static_cast<std::size_t>(pointPosition)); |
| 236 | + std::size_t firstSignificant = integerDigits.find_first_not_of('0'); |
| 237 | + integerDigits = firstSignificant == std::string::npos |
| 238 | + ? std::string() |
| 239 | + : integerDigits.substr(firstSignificant); |
| 240 | +} |
| 241 | + |
| 242 | +/** |
| 243 | + * @brief Rounds decimal digit strings at @p decimals places, half away from zero. |
| 244 | + * |
| 245 | + * .NET's number formatting rounds midpoints away from zero, which neither |
| 246 | + * `std::fixed` nor `std::to_chars` can be asked for. Doing it on the digits |
| 247 | + * avoids the question entirely. |
| 248 | + * |
| 249 | + * @param integerDigits Integer digits; rounded in place, may gain a digit. |
| 250 | + * @param fractionDigits Fraction digits; truncated in place to @p decimals. |
| 251 | + * @param decimals How many fraction digits to keep. |
| 252 | + */ |
| 253 | +inline void roundDecimalDigits(std::string& integerDigits, std::string& fractionDigits, |
| 254 | + std::size_t decimals) { |
| 255 | + if (fractionDigits.size() <= decimals) return; |
| 256 | + const bool roundUp = fractionDigits[decimals] >= '5'; |
| 257 | + fractionDigits.resize(decimals); |
| 258 | + if (!roundUp) return; |
| 259 | + for (std::size_t i = fractionDigits.size(); i-- > 0;) { |
| 260 | + if (fractionDigits[i] != '9') { ++fractionDigits[i]; return; } |
| 261 | + fractionDigits[i] = '0'; |
| 262 | + } |
| 263 | + for (std::size_t i = integerDigits.size(); i-- > 0;) { |
| 264 | + if (integerDigits[i] != '9') { ++integerDigits[i]; return; } |
| 265 | + integerDigits[i] = '0'; |
| 266 | + } |
| 267 | + integerDigits.insert(integerDigits.begin(), '1'); |
| 268 | +} |
| 269 | + |
| 270 | +/** |
| 271 | + * @brief Emits a value's digits through a custom numeric format string. |
| 272 | + * |
| 273 | + * Walks @p format so that every character which is not a digit placeholder is copied |
| 274 | + * through as a literal, which is what .NET does -- `(1f).ToString("Fx")` is `"Fx"`, |
| 275 | + * measured against the reference implementation rather than assumed. Integer digits are |
| 276 | + * consumed right to left, so any digits the format has no placeholder for are emitted at |
| 277 | + * the leftmost placeholder; a format with no integer placeholder at all emits none of |
| 278 | + * them. |
| 279 | + * |
| 280 | + * @param negative Whether the value is negative. |
| 281 | + * @param integerDigits The integer digits, without leading zeros. |
| 282 | + * @param fractionDigits The fraction digits, already rounded to the format's width. |
| 283 | + * @param format The custom format string. |
| 284 | + * @param shape The same format, already parsed. |
| 285 | + * @return The formatted text. |
| 286 | + */ |
| 287 | +[[nodiscard]] inline std::string emitCustomNumeric(bool negative, |
| 288 | + const std::string& integerDigits, |
| 289 | + std::string fractionDigits, |
| 290 | + const std::string& format, |
| 291 | + const CustomNumericFormat& shape) { |
| 292 | + const std::size_t pointInFormat = format.find('.'); |
| 293 | + const std::string integerFormat = |
| 294 | + pointInFormat == std::string::npos ? format : format.substr(0, pointInFormat); |
| 295 | + const std::string fractionFormat = |
| 296 | + pointInFormat == std::string::npos ? std::string() : format.substr(pointInFormat + 1); |
| 297 | + |
| 298 | + // A trailing `#` run is dropped only as far as the `0` placeholders allow. |
| 299 | + while (fractionDigits.size() > shape.minimumDecimals && !fractionDigits.empty() && |
| 300 | + fractionDigits.back() == '0') |
| 301 | + fractionDigits.pop_back(); |
| 302 | + |
| 303 | + std::string integerText; |
| 304 | + std::size_t remaining = integerDigits.size(); |
| 305 | + bool emittedAnyIntegerDigit = false; |
| 306 | + std::size_t emittedInGroup = 0; |
| 307 | + for (std::size_t i = integerFormat.size(); i-- > 0;) { |
| 308 | + const char c = integerFormat[i]; |
| 309 | + if (c == '0' || c == '#') { |
| 310 | + if (shape.groupSeparators && emittedInGroup == 3) { |
| 311 | + integerText.push_back(','); |
| 312 | + emittedInGroup = 0; |
| 313 | + } |
| 314 | + if (remaining > 0) { |
| 315 | + integerText.push_back(integerDigits[--remaining]); |
| 316 | + emittedAnyIntegerDigit = true; |
| 317 | + ++emittedInGroup; |
| 318 | + } else if (c == '0') { |
| 319 | + integerText.push_back('0'); |
| 320 | + emittedAnyIntegerDigit = true; |
| 321 | + ++emittedInGroup; |
| 322 | + } |
| 323 | + // The leftmost placeholder takes every digit the format had no room for. |
| 324 | + const bool leftmost = integerFormat.find_first_of("0#") == i; |
| 325 | + if (leftmost) { |
| 326 | + while (remaining > 0) { |
| 327 | + if (shape.groupSeparators && emittedInGroup == 3) { |
| 328 | + integerText.push_back(','); |
| 329 | + emittedInGroup = 0; |
| 330 | + } |
| 331 | + integerText.push_back(integerDigits[--remaining]); |
| 332 | + emittedAnyIntegerDigit = true; |
| 333 | + ++emittedInGroup; |
| 334 | + } |
| 335 | + } |
| 336 | + continue; |
| 337 | + } |
| 338 | + // A comma is the group separator, already accounted for; anything else is a literal. |
| 339 | + if (c == ',') continue; |
| 340 | + integerText.push_back(c); |
| 341 | + } |
| 342 | + (void)emittedAnyIntegerDigit; |
| 343 | + std::reverse(integerText.begin(), integerText.end()); |
| 344 | + |
| 345 | + std::string fractionText; |
| 346 | + std::size_t taken = 0; |
| 347 | + for (const char c : fractionFormat) { |
| 348 | + if (c == '0' || c == '#') { |
| 349 | + if (taken < fractionDigits.size()) { |
| 350 | + fractionText.push_back(fractionDigits[taken++]); |
| 351 | + } else if (c == '0') { |
| 352 | + fractionText.push_back('0'); |
| 353 | + } |
| 354 | + continue; |
| 355 | + } |
| 356 | + if (c == ',') continue; |
| 357 | + fractionText.push_back(c); |
| 358 | + } |
| 359 | + |
| 360 | + // .NET drops the decimal point when nothing was emitted after it: "0.##" of 1 is "1". |
| 361 | + const bool anyFractionDigit = |
| 362 | + fractionText.find_first_of("0123456789") != std::string::npos; |
| 363 | + |
| 364 | + std::string text; |
| 365 | + // A value that rounded away to nothing is not signed: (-0.4f).ToString("0") is "0". |
| 366 | + const bool anySignificant = integerDigits.find_first_not_of('0') != std::string::npos || |
| 367 | + fractionDigits.find_first_not_of('0') != std::string::npos; |
| 368 | + if (negative && anySignificant) text.push_back('-'); |
| 369 | + text += integerText; |
| 370 | + if (shape.hasDecimalPoint && anyFractionDigit) text.push_back('.'); |
| 371 | + text += fractionText; |
| 372 | + return text; |
| 373 | +} |
| 374 | + |
113 | 375 | } // namespace System::detail |
0 commit comments