internal/locale holds the conventions of about twenty locales, stdlib only: separators, date order and patterns, currency symbol and place, and the formula separators derived from them. numfmt renders number patterns, General and Accounting in a locale; value reads entries typed in a locale into the en-US form cells store, and writes them back. 1.234,5 is text in en-US, as in Sheets, rather than 1.2345.
- commit
- d4f901117717210274ce48f4d1e1c9c2f4ed42de
- parent
- 24d313d458adc9a8a0311d3deba8fb68ea2318f3
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 190 | ++++++++++++++++++ |
| A | internal/ | 66 | ++++++ |
| M | internal/ | 35 | +++- |
| M | internal/ | 23 | +- |
| M | internal/ | 11 | +- |
| M | internal/ | 19 | ++ |
| A | internal/ | 311 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 192 | +++++++++++++++++++ |
| M | internal/ | 7 | +- |
@@ -0,0 +1,190 @@
1+// Package locale is the table of spreadsheet locales, as Sheets' File >2+// Settings > Locale offers them: how numbers, dates and currency are3+// typed and shown, and how formulas separate their arguments. It holds4+// only the conventions, with no dependencies (no CLDR data): the5+// packages that parse and render use them at their edges, while cells,6+// formulas and files stay in the canonical form of en-US.7+package locale8+9+import (10+ "strings"11+)12+13+// DateOrder is the order of day, month and year in a date typed or14+// shown with numbers only.15+type DateOrder uint816+17+const (18+ MDY DateOrder = iota // 9/26/202619+ DMY // 26.09.202620+ YMD // 2026-09-2621+)22+23+func (o DateOrder) String() string { return [...]string{"MDY", "DMY", "YMD"}[o] }24+25+// Locale is one locale's conventions.26+type Locale struct {27+ Tag string // BCP 47, as written in files and the config: "de-DE"28+ Name string // as the picker lists it: "German (Germany)"29+30+ Decimal byte // the decimal separator: '.' or ','31+ Group string // the thousands separator shown: ",", ".", a no-break space, an apostrophe32+33+ Order DateOrder34+ DateSep byte // between the numbers of a date: '/', '.' or '-'35+ Date string // the Date format's pattern: "dd.mm.yyyy"36+ Time string // the Time format's pattern: "hh:mm:ss"37+38+ Currency string // the currency symbol: "€"39+ After bool // the symbol follows the number: "1.234,56 €"40+ Space bool // a space between the symbol and the number: "R$ 1.234,56"41+}42+43+// Canonical is en-US: what cells, formulas and files store, whatever the44+// locale shown.45+var Canonical = &table[0]46+47+// nbsp and nnbsp are the no-break spaces locales group digits with, so48+// a number never breaks across lines.49+const (50+ nbsp = " "51+ nnbsp = " "52+)53+54+// table lists the locales, en-US first. Each sticks to what Sheets shows55+// by default: Date, Time and Currency are the formats of Format > Number56+// in that locale.57+var table = []Locale{58+ {Tag: "en-US", Name: "English (United States)", Decimal: '.', Group: ",", Order: MDY, DateSep: '/', Date: "m/d/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},59+ {Tag: "en-GB", Name: "English (United Kingdom)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "£"},60+ {Tag: "en-CA", Name: "English (Canada)", Decimal: '.', Group: ",", Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "h:mm:ss am/pm", Currency: "$"},61+ {Tag: "en-AU", Name: "English (Australia)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/mm/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},62+ {Tag: "de-DE", Name: "German (Germany)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},63+ {Tag: "de-CH", Name: "German (Switzerland)", Decimal: '.', Group: "’", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "CHF", Space: true},64+ {Tag: "fr-FR", Name: "French (France)", Decimal: ',', Group: nnbsp, Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},65+ {Tag: "fr-CA", Name: "French (Canada)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "$", After: true, Space: true},66+ {Tag: "es-ES", Name: "Spanish (Spain)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},67+ {Tag: "es-MX", Name: "Spanish (Mexico)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "$"},68+ {Tag: "it-IT", Name: "Italian (Italy)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},69+ {Tag: "pt-BR", Name: "Portuguese (Brazil)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "R$", Space: true},70+ {Tag: "pt-PT", Name: "Portuguese (Portugal)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},71+ {Tag: "nl-NL", Name: "Dutch (Netherlands)", Decimal: ',', Group: ".", Order: DMY, DateSep: '-', Date: "d-m-yyyy", Time: "hh:mm:ss", Currency: "€", Space: true},72+ {Tag: "sv-SE", Name: "Swedish (Sweden)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "kr", After: true, Space: true},73+ {Tag: "da-DK", Name: "Danish (Denmark)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "kr.", After: true, Space: true},74+ {Tag: "nb-NO", Name: "Norwegian (Norway)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "kr", Space: true},75+ {Tag: "fi-FI", Name: "Finnish (Finland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},76+ {Tag: "pl-PL", Name: "Polish (Poland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "zł", After: true, Space: true},77+ {Tag: "cs-CZ", Name: "Czech (Czechia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "h:mm:ss", Currency: "Kč", After: true, Space: true},78+ {Tag: "ru-RU", Name: "Russian (Russia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "h:mm:ss", Currency: "₽", After: true, Space: true},79+ {Tag: "tr-TR", Name: "Turkish (Türkiye)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "₺"},80+ {Tag: "ja-JP", Name: "Japanese (Japan)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/mm/dd", Time: "h:mm:ss", Currency: "¥"},81+ {Tag: "zh-CN", Name: "Chinese (China)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},82+}83+84+// All returns every locale, en-US first, then as the table lists them.85+func All() []*Locale {86+ out := make([]*Locale, len(table))87+ for i := range table {88+ out[i] = &table[i]89+ }90+ return out91+}92+93+// Tags are the tags of every locale, in the table's order.94+func Tags() []string {95+ out := make([]string, len(table))96+ for i := range table {97+ out[i] = table[i].Tag98+ }99+ return out100+}101+102+// Lookup finds a locale by its tag, ignoring case and taking _ for -, so103+// "de-DE", "de_de" and "DE-de" are the same. A language alone ("de")104+// means its first locale in the table.105+func Lookup(tag string) (*Locale, bool) {106+ tag = strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")107+ if tag == "" {108+ return nil, false109+ }110+ for i := range table {111+ if strings.EqualFold(table[i].Tag, tag) {112+ return &table[i], true113+ }114+ }115+ if !strings.Contains(tag, "-") {116+ for i := range table {117+ if lang, _, _ := strings.Cut(table[i].Tag, "-"); strings.EqualFold(lang, tag) {118+ return &table[i], true119+ }120+ }121+ }122+ return nil, false123+}124+125+// FromPOSIX reads a POSIX locale, as LANG and LC_ALL hold it126+// ("de_DE.UTF-8", "fr_FR@euro"), returning the tag of the locale in the127+// table it names. C, POSIX and locales outside the table give "".128+func FromPOSIX(v string) string {129+ v, _, _ = strings.Cut(v, ".")130+ v, _, _ = strings.Cut(v, "@")131+ if v == "C" || v == "POSIX" {132+ return ""133+ }134+ if l, ok := Lookup(v); ok {135+ return l.Tag136+ }137+ return ""138+}139+140+// IsCanonical reports whether l types and shows everything as stored:141+// en-US, or nil.142+func (l *Locale) IsCanonical() bool { return l == nil || l == Canonical }143+144+// Or returns l, or the canonical locale when l is nil.145+func (l *Locale) Or() *Locale {146+ if l == nil {147+ return Canonical148+ }149+ return l150+}151+152+// ArgSep separates a formula's arguments: ; where the decimal separator153+// is a comma, as in Sheets, so 1,5 stays a number.154+func (l *Locale) ArgSep() byte {155+ if l != nil && l.Decimal == ',' {156+ return ';'157+ }158+ return ','159+}160+161+// ColSep separates the values of a row in an array literal: \ where the162+// argument separator is ;, which also separates an array's rows.163+func (l *Locale) ColSep() byte {164+ if l != nil && l.Decimal == ',' {165+ return '\\'166+ }167+ return ','168+}169+170+// IsGroup reports whether r separates thousands in l as typed: its own171+// separator, any space where it groups with a space, and ' for ’.172+func (l *Locale) IsGroup(r rune) bool {173+ if string(r) == l.Group {174+ return true175+ }176+ switch l.Group {177+ case nbsp, nnbsp:178+ return r == ' ' || r == ' ' || r == ' '179+ case "’":180+ return r == '\''181+ }182+ return false183+}184+185+// DateTime is the Date time format's pattern: the date, then the time on186+// the 24-hour clock, as Sheets shows it in every locale.187+func (l *Locale) DateTime() string {188+ t := strings.TrimSpace(strings.Replace(l.Time, "am/pm", "", 1))189+ return l.Date + " " + t190+}@@ -0,0 +1,66 @@
1+package locale_test2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+)8+9+func TestLookup(t *testing.T) {10+ for in, want := range map[string]string{11+ "de-DE": "de-DE", "de_de": "de-DE", "DE-de": "de-DE", " en-GB ": "en-GB",12+ "de": "de-DE", "fr": "fr-FR", "xx-YY": "", "": "", "de-AT": "",13+ } {14+ l, ok := locale.Lookup(in)15+ if got := ""; ok {16+ got = l.Tag17+ if got != want {18+ t.Errorf("Lookup(%q) = %s, want %q", in, got, want)19+ }20+ } else if want != "" {21+ t.Errorf("Lookup(%q) found nothing, want %s", in, want)22+ }23+ }24+}25+26+func TestFromPOSIX(t *testing.T) {27+ for in, want := range map[string]string{28+ "de_DE.UTF-8": "de-DE", "fr_FR@euro": "fr-FR", "en_US.UTF-8": "en-US", "C": "", "POSIX": "",29+ "C.UTF-8": "", "": "", "xx_YY.UTF-8": "", "pt_BR": "pt-BR",30+ } {31+ if got := locale.FromPOSIX(in); got != want {32+ t.Errorf("FromPOSIX(%q) = %q, want %q", in, got, want)33+ }34+ }35+}36+37+// TestTable checks every locale is whole: a separator a formula can tell38+// from its arguments, a date pattern in its own order, and tags that39+// look themselves up.40+func TestTable(t *testing.T) {41+ seen := map[string]bool{}42+ for _, l := range locale.All() {43+ if seen[l.Tag] {44+ t.Errorf("%s twice", l.Tag)45+ }46+ seen[l.Tag] = true47+ if got, ok := locale.Lookup(l.Tag); !ok || got != l {48+ t.Errorf("%s doesn't look itself up", l.Tag)49+ }50+ if l.Decimal != '.' && l.Decimal != ',' {51+ t.Errorf("%s: decimal %q", l.Tag, l.Decimal)52+ }53+ if l.ArgSep() == l.Decimal || l.ColSep() == l.Decimal || l.Group == string(l.Decimal) {54+ t.Errorf("%s: separators clash with the decimal %q", l.Tag, l.Decimal)55+ }56+ if l.Name == "" || l.Currency == "" || l.Date == "" || l.Time == "" {57+ t.Errorf("%s is missing a field", l.Tag)58+ }59+ }60+ if !locale.Canonical.IsCanonical() || locale.Canonical.Tag != "en-US" {61+ t.Error("en-US isn't canonical")62+ }63+ if got := locale.Canonical.DateTime(); got != "m/d/yyyy h:mm:ss" {64+ t.Errorf("en-US date time %q", got)65+ }66+}@@ -1,6 +1,11 @@
11 package numfmt22 3-import "strings"3+import (4+ "strings"5+ "unicode/utf8"6+7+ "github.com/FelineStateMachine/012/internal/locale"8+)49 510 // AdjustDecimals adds (delta > 0) or removes zeros after the decimal611 // point of every section of a number pattern, as Sheets' Increase and@@ -63,25 +68,41 @@ func adjustSection(sec string, delta int) string {
6368 // for a closing parenthesis, negatives in parentheses and zero as a dash.6469 // It reports false when the number doesn't fit.6570 func Accounting(v float64, dec, width int) (string, bool) {71+ return AccountingIn(v, dec, width, locale.Canonical)72+}73+74+// AccountingIn is Accounting as shown in loc: its separators, and its75+// currency symbol at the left of the cell, or after the number where loc76+// writes the symbol after it ("1.234,56 € ").77+func AccountingIn(v float64, dec, width int, loc *locale.Locale) (string, bool) {6678 whole, frac := Fixed(v, dec, HalfUp)6779 var num string6880 switch {6981 case strings.Trim(whole+frac, "0") == "":7082 num = "-" + strings.Repeat(" ", dec) + " "7183 case v < 0:72- num = "(" + Format(-v, "#,##0"+decimals(dec)) + ")"84+ num = "(" + FormatIn(-v, "#,##0"+decimals(dec), loc) + ")"7385 default:74- num = Format(v, "#,##0"+decimals(dec)) + " "86+ num = FormatIn(v, "#,##0"+decimals(dec), loc) + " "87+ }88+ sym := loc.Currency89+ if loc.After {90+ body := strings.TrimSuffix(num, " ") + " " + sym + " "91+ gap := width - utf8.RuneCountInString(body)92+ if gap < 0 {93+ return "", false94+ }95+ return strings.Repeat(" ", gap) + body, true7596 }76- gap := width - 1 - len(num)97+ gap := width - utf8.RuneCountInString(sym) - utf8.RuneCountInString(num)7798 switch {7899 case gap < 0:79100 return "", false80101 case gap >= 1:81- // Leave a column of padding before the $, like other cells.82- return " $" + strings.Repeat(" ", gap-1) + num, true102+ // Leave a column of padding before the symbol, like other cells.103+ return " " + sym + strings.Repeat(" ", gap-1) + num, true83104 }84- return "$" + strings.Repeat(" ", gap) + num, true105+ return sym + strings.Repeat(" ", gap) + num, true85106 }86107 87108 func decimals(n int) string {@@ -4,6 +4,8 @@ import (
44 "math"55 "strconv"66 "strings"7+8+ "github.com/FelineStateMachine/012/internal/locale"79 )810 911 // numLayout is where a number section puts each part of the number: the@@ -19,6 +21,7 @@ type numLayout struct {
1921 // literals sit among them too (a phone number pattern).2022 grouped bool2123 scale int // commas right after the last integer placeholder divide by 1000 each24+ loc *locale.Locale2225 }2326 2427 func layoutNumber(toks []ptok) numLayout {@@ -82,7 +85,7 @@ func (l *numLayout) digits(v float64) numDigits {
8285 switch {8386 case l.general && len(l.intPH) == 0:8487 // General shows its own decimals; placeholders after it get zeros.85- return numDigits{whole: General(v), frac: strings.Repeat("0", len(l.fracPH))}88+ return numDigits{whole: Localize(General(v), l.loc), frac: strings.Repeat("0", len(l.fracPH))}8689 case l.exp >= 0:8790 return l.scientific(v)8891 }@@ -145,9 +148,11 @@ func (l *numLayout) place(d numDigits) []string {
145148 return out146149 }147150 148-// formatNumber renders |v| with a number section; neg adds a minus sign.149-func formatNumber(v float64, neg bool, toks []ptok) string {151+// formatNumber renders |v| with a number section as shown in loc; neg152+// adds a minus sign.153+func formatNumber(v float64, neg bool, toks []ptok, loc *locale.Locale) string {150154 l := layoutNumber(toks)155+ l.loc = loc151156 for range l.percents {152157 v *= 100153158 }@@ -178,7 +183,7 @@ func (l *numLayout) write(b *strings.Builder, i int, t ptok, d numDigits, placed
178183 l.writeDigit(b, i, d, placed)179184 case ptDot:180185 if i == l.dot {181- b.WriteByte('.')186+ b.WriteByte(l.loc.Decimal)182187 }183188 case ptPercent:184189 b.WriteByte('%')@@ -209,7 +214,7 @@ func (l *numLayout) writeDigit(b *strings.Builder, i int, d numDigits, placed []
209214 for _, p := range l.intPH {210215 digits.WriteString(placed[p])211216 }212- b.WriteString(group(digits.String()))217+ b.WriteString(group(digits.String(), l.loc.Group))213218 case inInt:214219 if !l.grouped {215220 b.WriteString(placed[i])@@ -234,9 +239,9 @@ func expSign(sign string, neg bool) string {
234239 return "E"235240 }236241 237-// group inserts thousands separators into a run of digits (and leading238-// spaces from ? placeholders).239-func group(s string) string {242+// group inserts thousands separators sep into a run of digits (and243+// leading spaces from ? placeholders).244+func group(s, sep string) string {240245 lead := len(s) - len(strings.TrimLeft(s, " "))241246 digits := s[lead:]242247 if len(digits) <= 3 {@@ -250,7 +255,7 @@ func group(s string) string {
250255 }251256 for i := first; i < len(digits); i += 3 {252257 if i > 0 {253- b.WriteByte(',')258+ b.WriteString(sep)254259 }255260 b.WriteString(digits[i : i+3])256261 }@@ -9,6 +9,8 @@ import (
99 "math"1010 "strings"1111 "unicode/utf8"12+13+ "github.com/FelineStateMachine/012/internal/locale"1214 )1315 1416 // Number format patterns: "#,##0.00", "0.0%", "0.00E+00",@@ -44,7 +46,12 @@ type ptok struct {
4446 }4547 4648 // Format renders v with a number format pattern, as TEXT() does.47-func Format(v float64, pat string) string {49+func Format(v float64, pat string) string { return FormatIn(v, pat, locale.Canonical) }50+51+// FormatIn renders v with a number format pattern as shown in loc: the52+// pattern is written as in en-US ("#,##0.00") and the number shows loc's53+// decimal and thousands separators ("1.234,50" in de-DE).54+func FormatIn(v float64, pat string, loc *locale.Locale) string {4855 secs := splitSections(pat)4956 sec, neg := secs[0], v < 05057 switch {@@ -57,7 +64,7 @@ func Format(v float64, pat string) string {
5764 if isDatePattern(toks) {5865 return formatDate(v, toks)5966 }60- return formatNumber(math.Abs(v), neg, toks)67+ return formatNumber(math.Abs(v), neg, toks, loc)6168 }6269 6370 // splitSections splits a pattern on ; outside quotes and escapes.@@ -4,6 +4,8 @@ import (
44 "math"55 "strconv"66 "strings"7+8+ "github.com/FelineStateMachine/012/internal/locale"79 )810 911 // Rounding is how Fixed and Round treat the digits they drop.@@ -115,6 +117,23 @@ func General(v float64) string {
115117 return strconv.FormatFloat(r, 'f', -1, 64)116118 }117119 120+// Localize writes a number in General's form (digits, a point, an121+// exponent, no grouping) with loc's decimal separator.122+func Localize(s string, loc *locale.Locale) string {123+ if loc.Decimal == '.' {124+ return s125+ }126+ return strings.Replace(s, ".", string(loc.Decimal), 1)127+}128+129+// GeneralIn is General as shown in loc: 1,5 in de-DE.130+func GeneralIn(v float64, loc *locale.Locale) string { return Localize(General(v), loc) }131+132+// GeneralFitIn is GeneralFit as shown in loc.133+func GeneralFitIn(v float64, width int, loc *locale.Locale) string {134+ return Localize(GeneralFit(v, width), loc)135+}136+118137 // GeneralFit renders v in General format within width columns: as many119138 // digits as fit, rounding decimals first, then scientific notation, then120139 // #s.@@ -0,0 +1,311 @@
1+package value2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+)8+9+// Entries are stored as typed in en-US, the canonical locale, so a sheet10+// reads the same in every locale. What is typed in another locale is11+// rewritten to that form as it's entered (Canonicalize), and shown in12+// the locale's form when edited again (Localize): 1.234,5 in de-DE is13+// stored as 1,234.5, 26.09.2026 as 09/26/2026 and 12,50 € as $12.50.14+// Both keep what was typed as far as they can (grouping, leading zeros,15+// ISO dates, month names, times), and the value is the same either way.16+17+// ParseValueIn is ParseValue for an entry typed in loc.18+func ParseValueIn(s string, loc *locale.Locale) (float64, Format, bool) {19+ if loc.IsCanonical() {20+ return ParseValue(s)21+ }22+ c, ok := Canonicalize(s, loc)23+ if !ok {24+ return 0, Format{}, false25+ }26+ return ParseValue(c)27+}28+29+// Canonicalize rewrites a number, date or time typed in loc as it would30+// be typed in en-US, reporting false when s isn't one in loc.31+func Canonicalize(s string, loc *locale.Locale) (string, bool) {32+ if loc.IsCanonical() {33+ _, _, ok := ParseValue(s)34+ return s, ok35+ }36+ t := strings.TrimSpace(s)37+ if t == "" {38+ return s, false39+ }40+ if c, ok := canonicalNumber(t, loc); ok {41+ if _, _, ok := parseNumberFormat(c); ok {42+ return c, true43+ }44+ }45+ if c := canonicalDateTime(t, loc); c != "" {46+ if _, _, ok := ParseDateTime(c); ok {47+ return c, true48+ }49+ }50+ return s, false51+}52+53+// Localize is the inverse of Canonicalize: a number, date or time entry54+// as stored, written as typed in loc. Anything else is returned as is.55+func Localize(s string, loc *locale.Locale) string {56+ if loc.IsCanonical() {57+ return s58+ }59+ t := strings.TrimSpace(s)60+ if _, _, ok := parseNumberFormat(t); ok {61+ return localNumber(t, loc)62+ }63+ if _, _, ok := ParseDateTime(t); ok {64+ return localDateTime(t, loc)65+ }66+ return s67+}68+69+// spaces are the spaces that may stand between a number and its70+// currency symbol or percent sign.71+const spaces = " "72+73+// canonicalNumber rewrites a number typed in loc with en-US's74+// separators, $ for loc's currency symbol before it and % after it. It75+// reports false for characters that are no part of a number in loc,76+// such as the other locale's separators.77+func canonicalNumber(t string, loc *locale.Locale) (string, bool) {78+ sign := ""79+ if t[0] == '-' || t[0] == '+' {80+ sign, t = t[:1], t[1:]81+ }82+ cur := false83+ if rest, ok := strings.CutPrefix(t, loc.Currency); ok {84+ t, cur = strings.TrimLeft(rest, spaces), true85+ } else if rest, ok := strings.CutSuffix(t, loc.Currency); ok {86+ t, cur = strings.TrimRight(rest, spaces), true87+ }88+ pct := false89+ if rest, ok := strings.CutSuffix(t, "%"); ok {90+ t, pct = strings.TrimRight(rest, spaces), true91+ }92+ if t == "" {93+ return "", false94+ }95+ var b strings.Builder96+ b.Grow(len(t) + 3)97+ b.WriteString(sign)98+ if cur {99+ b.WriteByte('$')100+ }101+ for _, r := range t {102+ switch {103+ case r == rune(loc.Decimal):104+ b.WriteByte('.')105+ case loc.IsGroup(r):106+ b.WriteByte(',')107+ case r >= '0' && r <= '9', r == 'e', r == 'E', r == '+', r == '-':108+ b.WriteRune(r)109+ default:110+ return "", false111+ }112+ }113+ if pct {114+ b.WriteByte('%')115+ }116+ return b.String(), true117+}118+119+// localNumber writes a number entry as stored (en-US) as typed in loc.120+func localNumber(t string, loc *locale.Locale) string {121+ sign := ""122+ if t[0] == '-' || t[0] == '+' {123+ sign, t = t[:1], t[1:]124+ }125+ t, cur := strings.CutPrefix(t, "$")126+ var b strings.Builder127+ for i := 0; i < len(t); i++ {128+ switch c := t[i]; c {129+ case '.':130+ b.WriteByte(loc.Decimal)131+ case ',':132+ b.WriteString(loc.Group)133+ default:134+ b.WriteByte(c)135+ }136+ }137+ num := b.String()138+ if !cur {139+ return sign + num140+ }141+ gap := ""142+ if loc.Space {143+ gap = " "144+ }145+ if loc.After {146+ return sign + num + gap + loc.Currency147+ }148+ return sign + loc.Currency + gap + num149+}150+151+// canonicalDateTime rewrites a date typed with numbers in loc's order152+// (26.09.2026, 26/9) in en-US's (9/26/2026), keeping any time after it.153+// Other dates and times are returned as they are; "" is a date of154+// numbers that can't be one in loc.155+func canonicalDateTime(t string, loc *locale.Locale) string {156+ head, tail, spaced := strings.Cut(t, " ")157+ date, shaped := canonicalDate(head, loc)158+ if !shaped {159+ return t160+ }161+ if date == "" || !spaced {162+ return date163+ }164+ if loc.Decimal == ',' {165+ tail = strings.Replace(tail, ",", ".", 1) // 14:30:15,5166+ }167+ return date + " " + tail168+}169+170+// canonicalDate rewrites a date of numbers typed in loc in en-US's171+// order. shaped is false when s isn't numbers and separators; the date172+// is "" when they can't make a date in loc.173+func canonicalDate(s string, loc *locale.Locale) (date string, shaped bool) {174+ sep := dateSep(s, loc)175+ if sep == 0 {176+ return "", false177+ }178+ if sep == '.' {179+ s = strings.TrimSuffix(s, ".") // 26.09.2026.180+ }181+ p := strings.Split(s, string(sep))182+ if len(p) < 2 || len(p) > 3 || strings.Contains(s, string(sep)+string(sep)) || p[0] == "" || p[len(p)-1] == "" {183+ return "", true184+ }185+ if len(p) == 3 && len(p[0]) == 4 { // the year first, in every locale186+ switch {187+ case loc.Order == locale.YMD:188+ return p[1] + "/" + p[2] + "/" + p[0], true189+ case sep == '.':190+ return p[0] + "/" + p[1] + "/" + p[2], true191+ }192+ return s, true193+ }194+ var d, m, y string195+ switch {196+ case loc.Order == locale.DMY:197+ d, m = p[0], p[1]198+ case loc.Order == locale.YMD && len(p) == 3:199+ y, m, d = p[0], p[1], p[2]200+ default:201+ m, d = p[0], p[1]202+ }203+ if len(p) == 3 && y == "" {204+ y = p[2]205+ }206+ if y == "" {207+ return m + "/" + d, true208+ }209+ return m + "/" + d + "/" + y, true210+}211+212+// dateSep is the separator of a date of numbers typed in loc: / and -213+// everywhere, . where loc writes dates with it. It is 0 when s is214+// anything else, or mixes separators.215+func dateSep(s string, loc *locale.Locale) byte {216+ var sep byte217+ for i := 0; i < len(s); i++ {218+ c := s[i]219+ switch {220+ case isDigit(c):221+ continue222+ case c != '/' && c != '-' && (c != '.' || loc.DateSep != '.'):223+ return 0224+ case sep != 0 && c != sep:225+ return 0226+ }227+ sep = c228+ }229+ return sep230+}231+232+// localDateTime writes a date or time entry as stored (en-US) as typed233+// in loc: a date of numbers in loc's order with its separator. ISO234+// dates, month names and times stay as they are.235+func localDateTime(t string, loc *locale.Locale) string {236+ head, tail, spaced := strings.Cut(t, " ")237+ sep := dateSep(head, locale.Canonical)238+ if sep == 0 {239+ return t240+ }241+ p := strings.Split(head, string(sep))242+ if len(p[0]) == 4 || len(p) < 2 {243+ return t // the year first: as typed, in every locale244+ }245+ m, d := p[0], p[1]246+ parts := []string{m, d}247+ switch {248+ case loc.Order == locale.DMY:249+ parts = []string{d, m}250+ if len(p) == 3 {251+ parts = append(parts, p[2])252+ }253+ case loc.Order == locale.YMD && len(p) == 3:254+ parts = []string{p[2], m, d}255+ case len(p) == 3:256+ parts = append(parts, p[2])257+ }258+ sep = loc.DateSep259+ if len(parts) == 2 && sep == loc.Decimal {260+ sep = '/' // 26.9 would be a number in de-CH261+ }262+ date := strings.Join(parts, string(sep))263+ if !spaced {264+ return date265+ }266+ if loc.Decimal == ',' {267+ tail = strings.Replace(tail, ".", ",", 1)268+ }269+ return date + " " + tail270+}271+272+// currencyCode is the Currency format's pattern in loc, with dec273+// decimals: "$"#,##0.00 in en-US, #,##0.00" €" in de-DE.274+func currencyCode(loc *locale.Locale, dec string) string {275+ sym := loc.Currency276+ if loc.Space {277+ if loc.After {278+ sym = " " + sym279+ } else {280+ sym += " "281+ }282+ }283+ if loc.After {284+ return "#,##0" + dec + `"` + sym + `"`285+ }286+ return `"` + sym + `"#,##0` + dec287+}288+289+// CodeIn is Code as shown in loc: the Currency format with loc's symbol290+// and the Date, Time and Date time formats in loc's order. Patterns of291+// their own are the same in every locale.292+func (f Format) CodeIn(loc *locale.Locale) string {293+ if loc.IsCanonical() || f.Pattern != "" && (f.Kind == FmtCustom || f.Kind.IsTime()) {294+ return f.Code()295+ }296+ switch f.Kind {297+ case FmtCurrency:298+ dec := ""299+ if f.Decimals > 0 {300+ dec = "." + strings.Repeat("0", min(f.Decimals, MaxDecimals))301+ }302+ return currencyCode(loc, dec)303+ case FmtDate:304+ return loc.Date305+ case FmtTime:306+ return loc.Time307+ case FmtDateTime:308+ return loc.DateTime()309+ }310+ return f.Code()311+}@@ -0,0 +1,192 @@
1+package value_test2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+ "github.com/FelineStateMachine/012/internal/numfmt"8+ "github.com/FelineStateMachine/012/internal/value"9+)10+11+func loc(t *testing.T, tag string) *locale.Locale {12+ t.Helper()13+ l, ok := locale.Lookup(tag)14+ if !ok {15+ t.Fatalf("no locale %s", tag)16+ }17+ return l18+}19+20+func TestParseValueInLocales(t *testing.T) {21+ date := numfmt.DateSerial(2026, 9, 26)22+ cases := []struct {23+ tag, typed string24+ ok bool25+ v float6426+ kind value.FormatKind27+ canonical string28+ }{29+ {"en-US", "1,234.5", true, 1234.5, value.FmtNumber, "1,234.5"},30+ {"en-US", "1,5", false, 0, 0, ""},31+ {"de-DE", "1.234,5", true, 1234.5, value.FmtNumber, "1,234.5"},32+ {"de-DE", "1,5", true, 1.5, value.FmtAuto, "1.5"},33+ {"de-DE", "-0,25", true, -0.25, value.FmtAuto, "-0.25"},34+ {"de-DE", "12,5 %", true, 0.125, value.FmtPercent, "12.5%"},35+ {"de-DE", "1.234,50 €", true, 1234.5, value.FmtCurrency, "$1,234.50"},36+ {"de-DE", "-5 €", true, -5, value.FmtCurrency, "-$5"},37+ {"de-DE", "1,5E3", true, 1500, value.FmtScientific, "1.5E3"},38+ {"de-DE", "1,234.5", false, 0, 0, ""},39+ {"de-DE", "$5", false, 0, 0, ""},40+ {"de-DE", "26.09.2026", true, date, value.FmtDate, "09/26/2026"},41+ {"de-DE", "26.9.2026.", true, date, value.FmtDate, "9/26/2026"},42+ {"de-DE", "26/09/2026", true, date, value.FmtDate, "09/26/2026"},43+ {"de-DE", "9/26/2026", false, 0, 0, ""},44+ {"de-DE", "2026-09-26", true, date, value.FmtDate, "2026-09-26"},45+ {"de-DE", "Sep 26, 2026", true, date, value.FmtDate, "Sep 26, 2026"},46+ {"de-DE", "26.09.2026 14:30", true, date + numfmt.TimeSerial(14, 30, 0), value.FmtDateTime, "09/26/2026 14:30"},47+ {"de-DE", "14:30", true, numfmt.TimeSerial(14, 30, 0), value.FmtTime, "14:30"},48+ {"en-GB", "26/09/2026", true, date, value.FmtDate, "09/26/2026"},49+ {"en-GB", "9/26/2026", false, 0, 0, ""},50+ {"en-GB", "£1,200", true, 1200, value.FmtCurrency, "$1,200"},51+ {"en-GB", "$1,200", false, 0, 0, ""},52+ {"en-GB", "1.5", true, 1.5, value.FmtAuto, "1.5"},53+ {"fr-FR", "1 234,5", true, 1234.5, value.FmtNumber, "1,234.5"},54+ {"fr-FR", "1 234,5", true, 1234.5, value.FmtNumber, "1,234.5"},55+ {"fr-FR", "12 %", true, 0.12, value.FmtPercent, "12%"},56+ {"fr-FR", "3,50 €", true, 3.5, value.FmtCurrency, "$3.50"},57+ {"fr-FR", "1.5", false, 0, 0, ""},58+ {"es-ES", "1.000,25", true, 1000.25, value.FmtNumber, "1,000.25"},59+ {"it-IT", "26/9", true, numfmt.DateSerial(value.Now().Year(), 9, 26), value.FmtDate, "9/26"},60+ {"pt-BR", "R$ 1.234,56", true, 1234.56, value.FmtCurrency, "$1,234.56"},61+ {"nl-NL", "26-9-2026", true, date, value.FmtDate, "9/26/2026"},62+ {"nl-NL", "€ 5", true, 5, value.FmtCurrency, "$5"},63+ {"sv-SE", "2026-09-26", true, date, value.FmtDate, "09/26/2026"},64+ {"sv-SE", "100 kr", true, 100, value.FmtCurrency, "$100"},65+ {"pl-PL", "1 000,5 zł", true, 1000.5, value.FmtCurrency, "$1,000.5"},66+ {"de-CH", "1'234.5", true, 1234.5, value.FmtNumber, "1,234.5"},67+ {"de-CH", "CHF 5", true, 5, value.FmtCurrency, "$5"},68+ {"ja-JP", "2026/9/26", true, date, value.FmtDate, "9/26/2026"},69+ {"ja-JP", "¥1,000", true, 1000, value.FmtCurrency, "$1,000"},70+ {"zh-CN", "26/9/26", true, date, value.FmtDate, "9/26/26"},71+ {"de-CH", "26.9", true, 26.9, value.FmtAuto, "26.9"},72+ }73+ for _, c := range cases {74+ l := loc(t, c.tag)75+ v, f, ok := value.ParseValueIn(c.typed, l)76+ if ok != c.ok {77+ t.Errorf("%s %q: parsed %v, want %v", c.tag, c.typed, ok, c.ok)78+ continue79+ }80+ if !ok {81+ continue82+ }83+ if v != c.v || f.Kind != c.kind {84+ t.Errorf("%s %q = %v %v, want %v %v", c.tag, c.typed, v, f.Kind, c.v, c.kind)85+ }86+ got, _ := value.Canonicalize(c.typed, l)87+ if got != c.canonical {88+ t.Errorf("%s %q: canonical %q, want %q", c.tag, c.typed, got, c.canonical)89+ }90+ }91+}92+93+// TestLocalizeRoundTrips checks that every entry as stored, written in94+// each locale's form, is typed back to the same value and format.95+func TestLocalizeRoundTrips(t *testing.T) {96+ stored := []string{"1234.5", "1,234.5", "$1,234.50", "-$5", "-12.5%", "1.5E3", "0.001",97+ "9/26/2026", "09/26/2026", "9/26", "2026-09-26", "Sep 26, 2026", "26 Sep 2026",98+ "14:30", "2:30 PM", "25:30", "9/26/2026 14:30:05", "14:30:15.5"}99+ for _, l := range locale.All() {100+ for _, s := range stored {101+ want, wf, ok := value.ParseValue(s)102+ if !ok {103+ t.Fatalf("%q isn't a value", s)104+ }105+ shown := value.Localize(s, l)106+ got, gf, ok := value.ParseValueIn(shown, l)107+ if !ok || got != want || gf.Kind != wf.Kind {108+ t.Errorf("%s: %q shown as %q reads %v %v %v, want %v %v", l.Tag, s, shown, got, gf.Kind, ok, want, wf.Kind)109+ }110+ }111+ }112+}113+114+func TestLocalizeForms(t *testing.T) {115+ cases := []struct{ tag, stored, shown string }{116+ {"en-US", "$1,234.50", "$1,234.50"},117+ {"de-DE", "$1,234.50", "1.234,50 €"},118+ {"de-DE", "-12.5%", "-12,5%"},119+ {"de-DE", "9/26/2026", "26.9.2026"},120+ {"de-DE", "9/26/2026 14:30:15.5", "26.9.2026 14:30:15,5"},121+ {"de-DE", "2026-09-26", "2026-09-26"},122+ {"de-DE", "hello", "hello"},123+ {"en-GB", "$5", "£5"},124+ {"fr-FR", "1,234.5", "1 234,5"},125+ {"pt-BR", "$5", "R$ 5"},126+ {"ja-JP", "9/26/2026", "2026/9/26"},127+ {"nl-NL", "9/26", "26-9"},128+ }129+ for _, c := range cases {130+ if got := value.Localize(c.stored, loc(t, c.tag)); got != c.shown {131+ t.Errorf("%s %q shown as %q, want %q", c.tag, c.stored, got, c.shown)132+ }133+ }134+}135+136+func TestDisplayInLocales(t *testing.T) {137+ v := 1234.5138+ date := numfmt.DateSerial(2026, 9, 26) + numfmt.TimeSerial(14, 5, 0)139+ cases := []struct {140+ tag string141+ f value.Format142+ n float64143+ want string144+ }{145+ {"en-US", value.Preset(value.FmtNumber), v, "1,234.50"},146+ {"en-US", value.Preset(value.FmtCurrency), v, "$1,234.50"},147+ {"en-US", value.Preset(value.FmtDate), date, "9/26/2026"},148+ {"en-US", value.Preset(value.FmtTime), date, "2:05:00 PM"},149+ {"de-DE", value.Preset(value.FmtNumber), v, "1.234,50"},150+ {"de-DE", value.Preset(value.FmtPercent), 0.125, "12,50%"},151+ {"de-DE", value.Preset(value.FmtScientific), v, "1,23E+03"},152+ {"de-DE", value.Preset(value.FmtCurrency), -v, "-1.234,50 €"},153+ {"de-DE", value.Preset(value.FmtDate), date, "26.09.2026"},154+ {"de-DE", value.Preset(value.FmtTime), date, "14:05:00"},155+ {"de-DE", value.Preset(value.FmtDateTime), date, "26.09.2026 14:05:00"},156+ {"de-DE", value.Format{Kind: value.FmtDate, Pattern: "yyyy-mm-dd"}, date, "2026-09-26"},157+ {"de-DE", value.Format{Kind: value.FmtCustom, Pattern: `"$"#,##0.0`}, v, "$1.234,5"},158+ {"fr-FR", value.Preset(value.FmtNumber), v, "1 234,50"},159+ {"en-GB", value.Preset(value.FmtCurrency), v, "£1,234.50"},160+ {"en-GB", value.Preset(value.FmtDate), date, "26/09/2026"},161+ {"pt-BR", value.Preset(value.FmtCurrency), v, "R$ 1.234,50"},162+ {"sv-SE", value.Preset(value.FmtDate), date, "2026-09-26"},163+ {"ja-JP", value.Preset(value.FmtDate), date, "2026/09/26"},164+ {"de-CH", value.Preset(value.FmtNumber), v, "1’234.50"},165+ }166+ for _, c := range cases {167+ l := loc(t, c.tag)168+ if got := numfmt.FormatIn(c.n, c.f.CodeIn(l), l); got != c.want {169+ t.Errorf("%s %v: %q, want %q", c.tag, c.f, got, c.want)170+ }171+ }172+}173+174+func TestAccountingInLocales(t *testing.T) {175+ cases := []struct {176+ tag string177+ v float64178+ want string179+ }{180+ {"en-US", 1234.5, " $ 1,234.50 "},181+ {"en-US", -1234.5, " $ (1,234.50)"},182+ {"de-DE", 1234.5, " 1.234,50 € "},183+ {"de-DE", 0, " - € "},184+ {"pt-BR", 5, " R$ 5,00 "},185+ }186+ for _, c := range cases {187+ got, ok := numfmt.AccountingIn(c.v, 2, 13, loc(t, c.tag))188+ if !ok || got != c.want {189+ t.Errorf("%s %v: %q %v, want %q", c.tag, c.v, got, ok, c.want)190+ }191+ }192+}@@ -84,9 +84,12 @@ func parseNumberFormat(s string) (float64, Format, bool) {
8484 }8585 8686 // validGrouping checks thousands separators: groups of three digits87-// before the decimal point.87+// before the decimal point, and none after it (1.234,5 is text).8888 func validGrouping(s string) bool {89- intPart, _, _ := strings.Cut(s, ".")89+ intPart, frac, _ := strings.Cut(s, ".")90+ if strings.Contains(frac, ",") {91+ return false92+ }9093 groups := strings.Split(intPart, ",")9194 if len(groups[0]) == 0 || len(groups[0]) > 3 {9295 return false