feat(i18n): add localized number formatting helpers
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 <noreply@anthropic.com>
This commit is contained in:
parent
5e55f66de9
commit
7d1b1809cb
5 changed files with 479 additions and 0 deletions
|
|
@ -725,6 +725,31 @@ func (s *Service) handleI18nNamespace(key string, args []any) string {
|
||||||
return ActionFailed(verb, "")
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -975,6 +1000,38 @@ func toInt(v any) int {
|
||||||
return 0
|
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 {
|
func applyTemplate(text string, data any) string {
|
||||||
// Quick check for template syntax
|
// Quick check for template syntax
|
||||||
if !strings.Contains(text, "{{") {
|
if !strings.Contains(text, "{{") {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@
|
||||||
"punct": {
|
"punct": {
|
||||||
"label": ":",
|
"label": ":",
|
||||||
"progress": "..."
|
"progress": "..."
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"thousands": ".",
|
||||||
|
"decimal": ",",
|
||||||
|
"percent": "%s %%"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,11 @@
|
||||||
"punct": {
|
"punct": {
|
||||||
"label": ":",
|
"label": ":",
|
||||||
"progress": "..."
|
"progress": "..."
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"thousands": ",",
|
||||||
|
"decimal": ".",
|
||||||
|
"percent": "%s%%"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
|
||||||
239
pkg/i18n/numbers.go
Normal file
239
pkg/i18n/numbers.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
173
pkg/i18n/numbers_test.go
Normal file
173
pkg/i18n/numbers_test.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue