Skip to content

Commit 170e4ae

Browse files
committed
feat(SAMPLE-028): implement custom numeric format strings for Single and Double
XNA 4.0's ColorReplacement sample prints each colour channel with targetColor.X.ToString("0.000"). That is a CUSTOM numeric format string, not a standard specifier: .NET reads a format as standard only when it is one alphabetic character plus an optional integer precision. Single/Double::ToString implemented F/E/G/R/N and threw FormatException for everything else, so "0.000" could not be formatted at all. The custom grammar is now implemented in the shared System::detail helpers both types already format through, so they cannot drift apart: - the digit placeholders `0` (always emitted) and `#` (only when significant); - the decimal point, with the point itself dropped when nothing follows it; - `,` between integer placeholders as the group separator; - every other character copied through as a literal, which is what .NET does; - rounding half AWAY FROM ZERO, done on the decimal digits so neither std::fixed's round-half-to-even nor the FP rounding mode can enter into it; - a value that rounds away to nothing emits no sign: (-0.4f).ToString("0") is "0". Section separators (`;`), percent/permille scaling, the custom exponent forms and escaping are NOT implemented, and are refused with NotSupportedException rather than silently mis-emitted. Verified differentially against the reference implementation rather than against expectations: 28 value/format pairs run through both mono and this build, 28/28 identical. That measurement also overturned three pinned tests. They asserted that "Fx" and "Fz" throw FormatException as malformed precisions. They do not: neither has the standard shape, so .NET reads them as custom formats whose two characters are both literals, and returns "Fx"/"Fz" -- confirmed for the ToString path and for string.Format's. A single unrecognised letter ("Q") does still throw, which the sibling tests continue to pin. The three tests are corrected with the measurement cited in place.
1 parent 768a803 commit 170e4ae

6 files changed

Lines changed: 411 additions & 5 deletions

File tree

modules/core/include/System/Double.hpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "System/FormatException.hpp"
2323
#include "System/Math.hpp"
2424
#include "System/detail/FloatParseGrammar.hpp"
25+
#include "System/NotSupportedException.hpp"
2526
#include "System/detail/FloatTextFormat.hpp"
2627

2728
namespace System {
@@ -1158,6 +1159,29 @@ class Double {
11581159
if (format.empty()) return ToString(value);
11591160
if (std::isnan(value)) return "NaN";
11601161
if (std::isinf(value)) return value > 0 ? "Infinity" : "-Infinity";
1162+
// .NET reads a format as standard only when it is one letter plus an optional
1163+
// precision. Anything else -- "0.000", "#,##0.0" -- is a custom numeric format
1164+
// string, a separate grammar that the standard specifiers below cannot express.
1165+
if (!System::detail::isStandardNumericFormat(format)) {
1166+
const System::detail::CustomNumericFormat shape =
1167+
System::detail::parseCustomNumericFormat(format, [] {
1168+
throw System::NotSupportedException(
1169+
"This custom numeric format string uses a construct that is not "
1170+
"implemented: section separators, percent/permille scaling, custom "
1171+
"exponent forms and escaping are not supported.");
1172+
});
1173+
bool negative = false;
1174+
std::string integerDigits;
1175+
std::string fractionDigits;
1176+
// The shortest round-trippable text is what .NET Core formats from, so the
1177+
// custom format rounds the same digits .NET would round.
1178+
System::detail::splitDecimalText(ToString(value), negative, integerDigits,
1179+
fractionDigits);
1180+
System::detail::roundDecimalDigits(integerDigits, fractionDigits,
1181+
shape.hasDecimalPoint ? shape.maximumDecimals : 0);
1182+
return System::detail::emitCustomNumeric(negative, integerDigits, fractionDigits,
1183+
format, shape);
1184+
}
11611185
char type = format[0];
11621186
// SR-AUD-021 float slice (#1849 / CCF-006): guard the precision parse so a malformed
11631187
// precision (e.g. "Fz", or an oversized width) surfaces as System::FormatException

modules/core/include/System/Single.hpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "System/FormatException.hpp"
2323
#include "System/MathF.hpp"
2424
#include "System/detail/FloatParseGrammar.hpp"
25+
#include "System/NotSupportedException.hpp"
2526
#include "System/detail/FloatTextFormat.hpp"
2627

2728
namespace System {
@@ -1024,6 +1025,29 @@ class Single {
10241025
if (format.empty()) return ToString(value);
10251026
if (std::isnan(value)) return "NaN";
10261027
if (std::isinf(value)) return value > 0 ? "Infinity" : "-Infinity";
1028+
// .NET reads a format as standard only when it is one letter plus an optional
1029+
// precision. Anything else -- "0.000", "#,##0.0" -- is a custom numeric format
1030+
// string, a separate grammar that the standard specifiers below cannot express.
1031+
if (!System::detail::isStandardNumericFormat(format)) {
1032+
const System::detail::CustomNumericFormat shape =
1033+
System::detail::parseCustomNumericFormat(format, [] {
1034+
throw System::NotSupportedException(
1035+
"This custom numeric format string uses a construct that is not "
1036+
"implemented: section separators, percent/permille scaling, custom "
1037+
"exponent forms and escaping are not supported.");
1038+
});
1039+
bool negative = false;
1040+
std::string integerDigits;
1041+
std::string fractionDigits;
1042+
// The shortest round-trippable text is what .NET Core formats from, so the
1043+
// custom format rounds the same digits .NET would round.
1044+
System::detail::splitDecimalText(ToString(value), negative, integerDigits,
1045+
fractionDigits);
1046+
System::detail::roundDecimalDigits(integerDigits, fractionDigits,
1047+
shape.hasDecimalPoint ? shape.maximumDecimals : 0);
1048+
return System::detail::emitCustomNumeric(negative, integerDigits, fractionDigits,
1049+
format, shape);
1050+
}
10271051
char type = format[0];
10281052
// SR-AUD-021 float slice (#1849 / CCF-006): guard the precision parse so a malformed
10291053
// precision (e.g. "Fx", or an oversized width) surfaces as System::FormatException

modules/core/include/System/detail/FloatTextFormat.hpp

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
5+
#include <algorithm>
56
#include <array>
67
#include <charconv>
78
#include <cstddef>
@@ -110,4 +111,265 @@ template <class T>
110111
return std::string(buffer.data(), ptr);
111112
}
112113

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+
113375
} // namespace System::detail

modules/core/tests/System/DoubleTests.cpp

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,13 @@ TEST(DoubleTests, ToString_FormatR) {
458458
// SR-AUD-021 float slice (#1849 / CCF-006): a malformed precision no longer leaks a
459459
// std::stoi exception, and an unrecognised specifier is rejected loudly instead of
460460
// silently round-tripping. Matches the integer wrappers (#1847) and .NET.
461-
TEST(DoubleTests, ToString_MalformedPrecision_ThrowsFormatException) {
462-
EXPECT_THROW(Double::ToString(1.0, "Fz"), System::FormatException);
461+
// SAMPLE-028 correction: "Fz" is NOT a malformed standard specifier to .NET. A standard
462+
// numeric format string is one letter plus an optional integer precision; "Fz" fails that
463+
// shape, so .NET reads it as a CUSTOM format string in which both characters are literals
464+
// and returns "Fz". Measured directly against the reference implementation rather than
465+
// assumed. A single unrecognised letter does still throw.
466+
TEST(DoubleTests, ToString_MalformedPrecisionIsACustomFormatOfLiterals) {
467+
EXPECT_EQ(Double::ToString(1.0, "Fz"), "Fz");
463468
}
464469
TEST(DoubleTests, ToString_OversizedPrecision_ThrowsFormatException) {
465470
EXPECT_THROW(Double::ToString(1.0, "F99999999999"), System::FormatException);
@@ -653,3 +658,12 @@ TEST(DoubleTests, Ccf7_5_EverythingElseIsUnchanged) {
653658
EXPECT_THROW(Double::ToString(1.0, "Q"), System::FormatException);
654659
EXPECT_THROW(Double::ToString(1.0, "F99999999999"), System::FormatException);
655660
}
661+
662+
TEST(DoubleTests, CustomFormat_SharesSingleImplementationAndCannotDrift) {
663+
// Both types format through the same System::detail helpers, so this pins the double
664+
// side of that shared grammar.
665+
EXPECT_EQ(System::Double::ToString(3.14159, "0.00"), "3.14");
666+
EXPECT_EQ(System::Double::ToString(0.5, "0"), "1");
667+
EXPECT_EQ(System::Double::ToString(-1234.5, "#,##0.0"), "-1,234.5");
668+
EXPECT_EQ(System::Double::ToString(12.0, "F2"), "12.00");
669+
}

0 commit comments

Comments
 (0)