Integrate Money with .Net's formatting and parsing infrastructure - #90
Open
matthew25187 wants to merge 9 commits into
Open
Integrate Money with .Net's formatting and parsing infrastructure#90matthew25187 wants to merge 9 commits into
matthew25187 wants to merge 9 commits into
Conversation
Groundwork for integrating Money with .Net's formatting infrastructure. Fix the netstandard1.0 build, which failed because MoneyFormatter.Format used nullable annotations without guarding them for the target frameworks where nullable reference types are unavailable. Fix three latent defects in Money: Parse(string, IFormatProvider) did not negate the amount of a negative value without a currency, diverging from the three sibling parse methods; CompareTo(object) delegated to the amount and therefore threw for a Money argument; and Equals(object) used the reflection-based ValueType comparison on modern targets rather than the strongly typed comparison used elsewhere. Cache the ISO 4217 attribute and name lookups. These were resolved by reflection on every call, which formatting and parsing would otherwise repeat for every currency of every operation. Add currency symbols, generated from Unicode CLDR by a new script that follows the conventions of the existing generators. ISO 4217 defines no symbols, so CLDR is used as the source; symbols are exposed through GetCurrencySymbol extension methods on the currency code enums, in both the standard and narrow forms CLDR publishes. A reverse index supports resolving a symbol back to a currency where exactly one currency uses it. Consolidate the loading of currency format information into a single culture-aware factory, replacing two implementations whose error handling disagreed. Resource lookup no longer mutates the shared resource manager culture, which was not safe to do from several threads at once, and the current-culture instances now track a culture assigned after first use. Add NegativeSign, which several cultures render with U+2212 rather than the ASCII hyphen. Remove CurrencySymbol: a Money value carries its own currency, so the symbol is determined by the value being formatted rather than by the culture formatting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implement MoneyFormatter, which formats a Money value as a custom formatter obtained from the format provider chain, and route every Money.ToString overload through it. The previous implementation formatted the amount with .Net's currency specifier and then replaced the culture's currency symbol with the currency code, which failed silently whenever the symbol was absent from the rendered output or occurred more than once. The format specifiers are C for the currency symbol, H for the narrow currency symbol, I for the ISO 4217 currency code, N for the currency name and G for the amount alone, each optionally followed by a precision. A format string consisting of a currency code emits the code and asserts that it is the currency of the value, so that a mismatch is reported rather than formatted. C follows .Net's convention of denoting the currency symbol, which changes the output of the previously supported c and C formats: the currency code is now obtained with I. The culture supplies presentation only -- separators, group sizes, the placement of the currency token and the sign -- while the currency denoted and the default precision come from the value. Formatting a New Zealand Dollar value under an Indian culture therefore groups the amount in the Indian style but continues to denote New Zealand Dollars, rather than asserting that the amount is denominated in Rupees. A value with no currency emits no currency token at all. Add the satellite resources for sixteen cultures, without which every culture resolved to the invariant presentation. Grouping and rounding are delegated to decimal so that they match the intrinsic numeric types, including variable group sizes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parsing previously accepted an ISO 4217 currency code only. It now also accepts a currency symbol, but only where the currency that symbol denotes can be established with certainty. Most symbols denote exactly one currency and are resolved without any culture at all. The few that are shared -- the dollar sign alone is used by some thirty currencies -- are resolved from the currency of the culture being parsed for, and only when the caller has asked for that by including AllowAmbiguousCurrencySymbol. Where the currency cannot be determined, parsing fails: choosing one on the value's behalf would silently misstate the amount. A symbol is never resolved from the culture of the current thread, which says nothing about the origin of a value being parsed. Add MoneyStyles, which lets a caller accept only the elements they expect, as NumberStyles does for the intrinsic numeric types. A caller reading a feed of currency code values can reject anything carrying a symbol. Add ParseExact and TryParseExact, which accept a value only where formatting the result reproduces it under one of the expected formats. This keeps the accepted forms in step with those the formatter produces, and gives the round trip its strongest form: a value formatted with the currency code form parses back only from that form. Fix MoneyInfo.CurrentMoney, which threw when first read because it interpreted the language and region subtags of the current culture with Enum.TryParse, and neither is a System.Enum. It is now derived from the culture directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Declare IParsable, ISpanParsable and ISpanFormattable on the targets which define them, so that Money may be used by generic code written against those interfaces as the intrinsic numeric types are. Route the existing Parse and TryParse overloads through the parser added for symbol recognition, and remove the pattern machinery they used. That machinery matched an amount which admitted no group separators, so a value such as NZD1,234.50 parsed as 1234, silently discarding the minor units whenever a group separator was present. It also rebuilt an alternation of every currency code on each call. The behaviour of the existing overloads is otherwise unchanged: they continue to accept a currency code, and now also accept a currency symbol which denotes exactly one currency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cover the formatter across its specifiers, precision handling, culture patterns and fallback behaviour; the parser across the three ways a currency is identified; and the monetary format information across its construction, culture tracking and read-only semantics. Two tests are worth calling out because they pin decisions rather than mechanics. The first asserts that a value of one currency formatted under the culture of another applies that culture's grouping but continues to denote its own currency, so that a New Zealand Dollar value shown to an Indian reader is grouped in the Indian style yet is never rendered as Rupees. The second asserts that formatting a monetary value with an unrecognised format does not recurse: a monetary value is itself formattable, so resolving such a format through the formattable argument would resolve the formatter again and would not terminate. The parsing tests assert the negative cases as deliberately as the positive ones: a shared currency symbol must fail to parse without a culture to resolve it, and must fail again where the culture supplied does not account for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A package whose culture-specific resources are absent does not fail to load. Every culture silently falls back to the neutral resources, so monetary values are formatted with the invariant separators and patterns wherever the package is consumed, which is a difficult failure to attribute after the fact. Packing was verified to carry the satellite assemblies through, but the pack step runs against previously built artifacts rather than a fresh build, so their presence is now confirmed rather than assumed. The check was tested against a package with the satellite assemblies removed to establish that it does in fact fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add how-to guides for formatting and parsing, and reference pages for the types which had none: CurrencyFormatInfo, MoneyInfo, MoneyStyles, CurrencySymbolKind and MissingCultureResourceException. Both the how-to guide and the Money reference page call out the changed meaning of the C specifier, which now emits the currency symbol rather than the currency code, and direct readers to I for the code. Record the distinction between what the culture governs and what the value governs, since it is the question most likely to be asked of this design: a value of one currency formatted under the culture of another is grouped in that culture's style but continues to denote its own currency. Add the Unicode copyright notice required by the licence under which the currency symbol data is published, in a third-party notices file and in the header of the generated source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TryParseFormat raised a FormatException for a format string which named the wrong currency or an invalid precision. A method offering to try an action must report failure by its return value; raising an exception defeats the purpose of being able to attempt the action at all. The method now returns the outcome of interpreting the format string, and is named accordingly. Format raises the exception, which is where the decision belongs: it makes no undertaking to be free of exceptions, and it is the method the caller asked to format the value. The distinction between an unrecognised format string and an invalid one is now explicit in the outcome rather than implied by control flow. The former may still be a custom numeric format string, while the latter must not be reinterpreted as one, because handing a currency specifier to the number formatter would emit the culture's own currency symbol in place of that of the value. Behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing them The conditional compilation directives named .NET 8.0, but ISpanFormattable has been defined since .NET 6.0 and IParsable and ISpanParsable since .NET 7.0. The earliest version covering all three is .NET 7.0, so naming a later one withheld the interfaces from targets which support them. Update the documentation to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds native formatting and parsing support to the
Moneytype, so that a monetary value can be formatted and parsed exactly as an intrinsic numeric type is.Breaking change
Cnow emits the currency symbol rather than the ISO 4217 currency code. UseIwhere you previously usedC.money.ToString("C2", enNz)NZD1,234.50NZ$1,234.50money.ToString("I2", enNz)NZD1,234.50The mapping now matches the meaning the specifier has for the intrinsic numeric types, so a caller writing
money.ToString("C2", culture)gets whatdecimal.ToString("C2", culture)would give them. This warrants a major version bump for the package.Format specifiers
CNZ$1,234.50H$1,234.50INZD1,234.50NNew Zealand Dollar1,234.50G, or no format1234.5NZD1,234.50Each may carry a precision (
C2,I3); the default is the number of minor units of the currency.The culture governs presentation, not currency
A
Moneyvalue carries its own currency, so the culture supplied determines only the separators, group sizes, token placement and sign. Which currency is denoted, and the default precision, come from the value.The amount is grouped in the Indian style because that culture was asked for, but the value is still denoted in New Zealand Dollars. Substituting the culture's symbol would assert that a quantity of New Zealand Dollars is the same quantity of Rupees, which is false and which neither a reader nor a subsequent parse could detect.
Parsing
A currency is identified from a currency code, which is never ambiguous, or from a symbol where the currency it denotes can be established with certainty. Measured against CLDR, 159 of the 164 currencies carrying a symbol are resolvable without any culture at all; only GBP, JPY, KRW, USD and VEF need one, being the currencies holding a bare shared glyph.
Where the currency cannot be determined, parsing fails. It never selects one on the value's behalf, and never resolves a shared symbol from the culture of the current thread.
MoneyStyleslets a caller accept only the elements they expect, asNumberStylesdoes for the intrinsic numeric types.ParseExactaccepts a value only where formatting the result reproduces it under one of the named formats.Defects found and fixed
Four pre-existing defects surfaced during the work:
Parsesilently truncatedNZD1,234.50to1234. The pattern admitted no group separators, so the minor units were discarded whenever one was present. The most consequential of these.netstandard1.0did not build, owing to unguarded nullable annotations.MoneyInfo.CurrentMoneythrew when first read, interpreting the subtags of the current culture withEnum.TryParsewhere neither is aSystem.Enum.CompareTo(object)threw for aMoneyargument;Equals(object)used the reflection-based comparison on modern targets.Two further defects were introduced and fixed within the branch: a value with no currency emitted the culture's symbol, and a malformed precision fell through to the number formatter, which emitted the culture's symbol in place of the value's currency.
Currency symbol data
ISO 4217 defines no currency symbols, so they are sourced from Unicode CLDR by a new generator script following the conventions of the existing ones.
Iso4217CurrencyCodeAttributeand the existing generators are untouched. Symbols are exposed throughGetCurrencySymbolextension methods on both currency code enums, in the standard and narrow forms CLDR publishes.The Unicode copyright notice required by the licence is recorded in
THIRD-PARTY-NOTICES.mdand in the generated source.Notable design decisions
CurrencyFormatInfohas noCurrencySymbol.NumberFormatInfoneeds one becausedecimalcarries no currency; aMoneyvalue does, so the symbol always comes from the value.CurrencyCodeis retained because it is what resolves a shared symbol when parsing.CurrencySymbolsis internal. Symbols have no unique identity —$is shared by thirty currencies — so a public symbol type would offer naming without round-tripping.Verification
Left to the reviewer
Parse/TryParseoverloads now route through the new parser. This fixes the truncation defect but does widen them to accept unambiguous currency symbols. Say if you would prefer them frozen.🤖 Generated with Claude Code