From 7d1b1809cb98e68fbe802d246ac32eb76e9576e4 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 14:39:15 +0000 Subject: [PATCH] feat(i18n): add localized number formatting helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New i18n.* namespace patterns for number formatting: - T("i18n.number", 1234567) → "1,234,567" (en) / "1.234.567" (de) - T("i18n.decimal", 1234.56) → "1,234.56" (en) / "1.234,56" (de) - T("i18n.percent", 0.85) → "85%" (en) / "85 %" (de) - T("i18n.bytes", 1536000) → "1.5 MB" (en) / "1,5 MB" (de) - T("i18n.ordinal", 3) → "3rd" (en) / "3." (de) Also available as direct functions: - FormatNumber(n), FormatDecimal(f), FormatPercent(f) - FormatBytes(n), FormatOrdinal(n) Language-aware formatting for en, de, fr, es, zh. Co-Authored-By: Claude Opus 4.5 --- pkg/i18n/i18n.go | 57 +++++++++ pkg/i18n/locales/de.json | 5 + pkg/i18n/locales/en_GB.json | 5 + pkg/i18n/numbers.go | 239 ++++++++++++++++++++++++++++++++++++ pkg/i18n/numbers_test.go | 173 ++++++++++++++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 pkg/i18n/numbers.go create mode 100644 pkg/i18n/numbers_test.go diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index 04f6163f..a35c13b9 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -725,6 +725,31 @@ func (s *Service) handleI18nNamespace(key string, args []any) string { return ActionFailed(verb, "") } + // i18n.number → FormatNumber(n) + if key == "i18n.number" && len(args) > 0 { + return FormatNumber(toInt64(args[0])) + } + + // i18n.decimal → FormatDecimal(f) + if key == "i18n.decimal" && len(args) > 0 { + return FormatDecimal(toFloat64(args[0])) + } + + // i18n.percent → FormatPercent(f) + if key == "i18n.percent" && len(args) > 0 { + return FormatPercent(toFloat64(args[0])) + } + + // i18n.bytes → FormatBytes(n) + if key == "i18n.bytes" && len(args) > 0 { + return FormatBytes(toInt64(args[0])) + } + + // i18n.ordinal → FormatOrdinal(n) + if key == "i18n.ordinal" && len(args) > 0 { + return FormatOrdinal(toInt(args[0])) + } + return "" } @@ -975,6 +1000,38 @@ func toInt(v any) int { return 0 } +func toInt64(v any) int64 { + switch n := v.(type) { + case int: + return int64(n) + case int64: + return n + case int32: + return int64(n) + case float64: + return int64(n) + case float32: + return int64(n) + } + return 0 +} + +func toFloat64(v any) float64 { + switch n := v.(type) { + case float64: + return n + case float32: + return float64(n) + case int: + return float64(n) + case int64: + return float64(n) + case int32: + return float64(n) + } + return 0 +} + func applyTemplate(text string, data any) string { // Quick check for template syntax if !strings.Contains(text, "{{") { diff --git a/pkg/i18n/locales/de.json b/pkg/i18n/locales/de.json index 731ae36a..85e139a6 100644 --- a/pkg/i18n/locales/de.json +++ b/pkg/i18n/locales/de.json @@ -28,6 +28,11 @@ "punct": { "label": ":", "progress": "..." + }, + "number": { + "thousands": ".", + "decimal": ",", + "percent": "%s %%" } }, "prompt": { diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index c9a23d62..e605c385 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -94,6 +94,11 @@ "punct": { "label": ":", "progress": "..." + }, + "number": { + "thousands": ",", + "decimal": ".", + "percent": "%s%%" } }, "prompt": { diff --git a/pkg/i18n/numbers.go b/pkg/i18n/numbers.go new file mode 100644 index 00000000..3259236a --- /dev/null +++ b/pkg/i18n/numbers.go @@ -0,0 +1,239 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// NumberFormat defines locale-specific number formatting rules. +type NumberFormat struct { + ThousandsSep string // "," for en, "." for de + DecimalSep string // "." for en, "," for de + PercentFmt string // "%s%%" for en, "%s %%" for de (space before %) +} + +// Default number formats by language. +var numberFormats = map[string]NumberFormat{ + "en": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}, + "de": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s %%"}, + "fr": {ThousandsSep: " ", DecimalSep: ",", PercentFmt: "%s %%"}, + "es": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s%%"}, + "zh": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}, +} + +// getNumberFormat returns the number format for the current language. +func getNumberFormat() NumberFormat { + lang := currentLangForGrammar() + // Extract base language (en-GB → en) + if idx := strings.IndexAny(lang, "-_"); idx > 0 { + lang = lang[:idx] + } + if fmt, ok := numberFormats[lang]; ok { + return fmt + } + return numberFormats["en"] // fallback +} + +// FormatNumber formats an integer with locale-specific thousands separators. +// +// FormatNumber(1234567) // "1,234,567" (en) or "1.234.567" (de) +func FormatNumber(n int64) string { + nf := getNumberFormat() + return formatIntWithSep(n, nf.ThousandsSep) +} + +// FormatDecimal formats a float with locale-specific separators. +// Uses up to 2 decimal places, trimming trailing zeros. +// +// FormatDecimal(1234.5) // "1,234.5" (en) or "1.234,5" (de) +// FormatDecimal(1234.00) // "1,234" (en) or "1.234" (de) +func FormatDecimal(f float64) string { + return FormatDecimalN(f, 2) +} + +// FormatDecimalN formats a float with N decimal places. +// +// FormatDecimalN(1234.5678, 3) // "1,234.568" (en) +func FormatDecimalN(f float64, decimals int) string { + nf := getNumberFormat() + + // Split into integer and fractional parts + intPart := int64(f) + fracPart := math.Abs(f - float64(intPart)) + + // Format integer part with thousands separator + intStr := formatIntWithSep(intPart, nf.ThousandsSep) + + // Format fractional part + if decimals <= 0 || fracPart == 0 { + return intStr + } + + // Round and format fractional part + multiplier := math.Pow(10, float64(decimals)) + fracInt := int64(math.Round(fracPart * multiplier)) + + if fracInt == 0 { + return intStr + } + + // Format with leading zeros, then trim trailing zeros + fracStr := fmt.Sprintf("%0*d", decimals, fracInt) + fracStr = strings.TrimRight(fracStr, "0") + + return intStr + nf.DecimalSep + fracStr +} + +// FormatPercent formats a decimal as a percentage. +// +// FormatPercent(0.85) // "85%" (en) or "85 %" (de) +// FormatPercent(0.333) // "33.3%" (en) +// FormatPercent(1.5) // "150%" (en) +func FormatPercent(f float64) string { + nf := getNumberFormat() + pct := f * 100 + + // Format the number part + var numStr string + if pct == float64(int64(pct)) { + numStr = strconv.FormatInt(int64(pct), 10) + } else { + numStr = FormatDecimalN(pct, 1) + } + + return fmt.Sprintf(nf.PercentFmt, numStr) +} + +// FormatBytes formats bytes as human-readable size. +// +// FormatBytes(1536) // "1.5 KB" +// FormatBytes(1536000) // "1.5 MB" +// FormatBytes(1536000000) // "1.4 GB" +func FormatBytes(bytes int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + TB = GB * 1024 + ) + + nf := getNumberFormat() + + var value float64 + var unit string + + switch { + case bytes >= TB: + value = float64(bytes) / TB + unit = "TB" + case bytes >= GB: + value = float64(bytes) / GB + unit = "GB" + case bytes >= MB: + value = float64(bytes) / MB + unit = "MB" + case bytes >= KB: + value = float64(bytes) / KB + unit = "KB" + default: + return fmt.Sprintf("%d B", bytes) + } + + // Format with 1 decimal place, trim .0 + intPart := int64(value) + fracPart := value - float64(intPart) + + if fracPart < 0.05 { + return fmt.Sprintf("%d %s", intPart, unit) + } + + fracDigit := int(math.Round(fracPart * 10)) + if fracDigit == 10 { + return fmt.Sprintf("%d %s", intPart+1, unit) + } + + return fmt.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit) +} + +// FormatOrdinal formats a number as an ordinal. +// +// FormatOrdinal(1) // "1st" (en) or "1." (de) +// FormatOrdinal(2) // "2nd" (en) or "2." (de) +// FormatOrdinal(3) // "3rd" (en) or "3." (de) +// FormatOrdinal(11) // "11th" (en) or "11." (de) +func FormatOrdinal(n int) string { + lang := currentLangForGrammar() + // Extract base language + if idx := strings.IndexAny(lang, "-_"); idx > 0 { + lang = lang[:idx] + } + + // Most languages just use number + period + switch lang { + case "en": + return formatEnglishOrdinal(n) + default: + return fmt.Sprintf("%d.", n) + } +} + +// formatEnglishOrdinal returns English ordinal suffix. +func formatEnglishOrdinal(n int) string { + abs := n + if abs < 0 { + abs = -abs + } + + // Special cases for 11, 12, 13 + if abs%100 >= 11 && abs%100 <= 13 { + return fmt.Sprintf("%dth", n) + } + + switch abs % 10 { + case 1: + return fmt.Sprintf("%dst", n) + case 2: + return fmt.Sprintf("%dnd", n) + case 3: + return fmt.Sprintf("%drd", n) + default: + return fmt.Sprintf("%dth", n) + } +} + +// formatIntWithSep formats an integer with thousands separator. +func formatIntWithSep(n int64, sep string) string { + if sep == "" { + return strconv.FormatInt(n, 10) + } + + negative := n < 0 + if negative { + n = -n + } + + str := strconv.FormatInt(n, 10) + if len(str) <= 3 { + if negative { + return "-" + str + } + return str + } + + // Insert separators from right to left + var result strings.Builder + for i, c := range str { + if i > 0 && (len(str)-i)%3 == 0 { + result.WriteString(sep) + } + result.WriteRune(c) + } + + if negative { + return "-" + result.String() + } + return result.String() +} diff --git a/pkg/i18n/numbers_test.go b/pkg/i18n/numbers_test.go new file mode 100644 index 00000000..e9bbeccc --- /dev/null +++ b/pkg/i18n/numbers_test.go @@ -0,0 +1,173 @@ +package i18n + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatNumber(t *testing.T) { + svc, err := New() + require.NoError(t, err) + SetDefault(svc) + + tests := []struct { + name string + input int64 + expected string + }{ + {"zero", 0, "0"}, + {"small", 123, "123"}, + {"thousands", 1234, "1,234"}, + {"millions", 1234567, "1,234,567"}, + {"negative", -1234567, "-1,234,567"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatNumber(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatDecimal(t *testing.T) { + svc, err := New() + require.NoError(t, err) + SetDefault(svc) + + tests := []struct { + name string + input float64 + expected string + }{ + {"integer", 1234.0, "1,234"}, + {"one decimal", 1234.5, "1,234.5"}, + {"two decimals", 1234.56, "1,234.56"}, + {"trailing zeros", 1234.50, "1,234.5"}, + {"small", 0.5, "0.5"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatDecimal(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatPercent(t *testing.T) { + svc, err := New() + require.NoError(t, err) + SetDefault(svc) + + tests := []struct { + name string + input float64 + expected string + }{ + {"whole", 0.85, "85%"}, + {"decimal", 0.333, "33.3%"}, + {"over 100", 1.5, "150%"}, + {"zero", 0.0, "0%"}, + {"one", 1.0, "100%"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatPercent(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatBytes(t *testing.T) { + svc, err := New() + require.NoError(t, err) + SetDefault(svc) + + tests := []struct { + name string + input int64 + expected string + }{ + {"bytes", 500, "500 B"}, + {"KB", 1536, "1.5 KB"}, + {"MB", 1572864, "1.5 MB"}, + {"GB", 1610612736, "1.5 GB"}, + {"exact KB", 1024, "1 KB"}, + {"exact MB", 1048576, "1 MB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatBytes(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatOrdinal(t *testing.T) { + svc, err := New() + require.NoError(t, err) + SetDefault(svc) + + tests := []struct { + name string + input int + expected string + }{ + {"1st", 1, "1st"}, + {"2nd", 2, "2nd"}, + {"3rd", 3, "3rd"}, + {"4th", 4, "4th"}, + {"11th", 11, "11th"}, + {"12th", 12, "12th"}, + {"13th", 13, "13th"}, + {"21st", 21, "21st"}, + {"22nd", 22, "22nd"}, + {"23rd", 23, "23rd"}, + {"100th", 100, "100th"}, + {"101st", 101, "101st"}, + {"111th", 111, "111th"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatOrdinal(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestI18nNumberNamespace(t *testing.T) { + svc, err := New() + require.NoError(t, err) + SetDefault(svc) + + t.Run("i18n.number", func(t *testing.T) { + result := svc.T("i18n.number", 1234567) + assert.Equal(t, "1,234,567", result) + }) + + t.Run("i18n.decimal", func(t *testing.T) { + result := svc.T("i18n.decimal", 1234.56) + assert.Equal(t, "1,234.56", result) + }) + + t.Run("i18n.percent", func(t *testing.T) { + result := svc.T("i18n.percent", 0.85) + assert.Equal(t, "85%", result) + }) + + t.Run("i18n.bytes", func(t *testing.T) { + result := svc.T("i18n.bytes", 1572864) + assert.Equal(t, "1.5 MB", result) + }) + + t.Run("i18n.ordinal", func(t *testing.T) { + result := svc.T("i18n.ordinal", 3) + assert.Equal(t, "3rd", result) + }) +}