Просмотр исходного кода

fix(locale): drop unused arguments when formatting translations

Translation forms that intentionally omit the count, such as the Arabic
dual, no longer render a trailing %!(EXTRA ...) marker. The printer now
formats with the supplied arguments only when the string has a real
directive, and skips them otherwise while still unescaping percents.

This also fixes the same issue in the Polish and Romanian one-forms, and
makes the missing-translation fallback return the bare key.
Frédéric Guillot 6 дней назад
Родитель
Сommit
0cfd0798b9
3 измененных файлов с 92 добавлено и 6 удалено
  1. 4 4
      internal/locale/error_test.go
  2. 32 2
      internal/locale/printer.go
  3. 56 0
      internal/locale/printer_test.go

+ 4 - 4
internal/locale/error_test.go

@@ -74,9 +74,9 @@ func TestLocalizedErrorWrapper_Translate(t *testing.T) {
 		t.Errorf("Expected French translation %q, got %q", expected, result)
 	}
 
-	// Test with missing language (should use key as fallback with args applied)
+	// Test with missing language (should fall back to the untranslated key)
 	result = wrapper.Translate("invalid_lang")
-	expected = "error.test_key%!(EXTRA string=test message, int=404)"
+	expected = "error.test_key"
 	if result != expected {
 		t.Errorf("Expected fallback translation %q, got %q", expected, result)
 	}
@@ -157,7 +157,7 @@ func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
 	localizedErr := NewLocalizedError("error.missing", "arg1")
 
 	result := localizedErr.String()
-	expected := "error.missing%!(EXTRA string=arg1)"
+	expected := "error.missing"
 	if result != expected {
 		t.Errorf("Expected String() result %q, got %q", expected, result)
 	}
@@ -217,7 +217,7 @@ func TestLocalizedError_Translate(t *testing.T) {
 
 	// Test with missing language
 	result = localizedErr.Translate("invalid_lang")
-	expected = "error.permission%!(EXTRA string=admin panel)"
+	expected = "error.permission"
 	if result != expected {
 		t.Errorf("Expected fallback translation %q, got %q", expected, result)
 	}

+ 32 - 2
internal/locale/printer.go

@@ -26,7 +26,7 @@ func (p *Printer) Print(key string) string {
 
 // Printf is like fmt.Printf, but using language-specific formatting.
 func (p *Printer) Printf(key string, args ...any) string {
-	return fmt.Sprintf(p.Print(key), args...)
+	return formatTranslation(p.Print(key), args...)
 }
 
 // Plural returns the translation of the given key by using the language plural form.
@@ -39,9 +39,39 @@ func (p *Printer) Plural(key string, n int, args ...any) string {
 	if choices, found := dict.plurals[key]; found {
 		index := getPluralForm(p.language, n)
 		if len(choices) > index {
-			return fmt.Sprintf(choices[index], args...)
+			return formatTranslation(choices[index], args...)
 		}
 	}
 
 	return key
 }
+
+// formatTranslation skips extra arguments when the translation references no argument,
+// so plural forms that omit the count (e.g. the Arabic dual "دقيقتين") don't get
+// a trailing %!(EXTRA ...) marker. Escaped percents are still processed by fmt.
+func formatTranslation(format string, args ...any) string {
+	if !hasFormattingDirective(format) {
+		return fmt.Sprintf(format, []any{}...)
+	}
+	return fmt.Sprintf(format, args...)
+}
+
+// hasFormattingDirective reports whether the format should be handled with the
+// supplied arguments. It treats "%%" as a literal percent and lets fmt validate
+// any other percent sequence, including a dangling "%".
+func hasFormattingDirective(format string) bool {
+	for index := 0; index < len(format); index++ {
+		if format[index] != '%' {
+			continue
+		}
+		if index+1 >= len(format) {
+			return true
+		}
+		if format[index+1] == '%' {
+			index++ // skip the escaped percent
+			continue
+		}
+		return true
+	}
+	return false
+}

+ 56 - 0
internal/locale/printer_test.go

@@ -354,3 +354,59 @@ func TestPluralWithVariousLanguageRules(t *testing.T) {
 		}
 	}
 }
+
+func TestPluralFormWithoutPlaceholder(t *testing.T) {
+	defaultCatalog = catalog{
+		"ar_SA": translationDict{
+			plurals: map[string][]string{
+				// The Arabic dual omits the count by design.
+				"minutes": {"%d دقيقة", "دقيقة واحدة", "دقيقتين", "%d دقائق", "%d دقيقة", "%d دقيقة"},
+			},
+		},
+	}
+
+	printer := NewPrinter("ar_SA")
+
+	if got := printer.Plural("minutes", 1, 1); got != "دقيقة واحدة" {
+		t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
+	}
+	if got := printer.Plural("minutes", 2, 2); got != "دقيقتين" {
+		t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
+	}
+	if got := printer.Plural("minutes", 5, 5); got != "5 دقائق" {
+		t.Errorf(`Plural form with placeholder should be formatted, got %q`, got)
+	}
+}
+
+func TestPrintfUnescapesLiteralPercentWithoutArgs(t *testing.T) {
+	defaultCatalog = catalog{
+		"en_US": translationDict{
+			singulars: map[string]string{
+				"media.completion": "Mark as read at 90%% completion",
+			},
+		},
+	}
+
+	got := NewPrinter("en_US").Printf("media.completion")
+	expected := "Mark as read at 90% completion"
+	if got != expected {
+		t.Errorf(`Escaped percent should be unescaped, got %q instead of %q`, got, expected)
+	}
+}
+
+func TestHasFormattingDirective(t *testing.T) {
+	tests := map[string]bool{
+		"دقيقتين":   false,
+		"%d دقيقة":  true,
+		"90%% done": false, // escaped percent consumes no argument
+		"%d of %s":  true,
+		"":          false,
+		"%":         true,
+	}
+
+	for format, expected := range tests {
+		if got := hasFormattingDirective(format); got != expected {
+			t.Errorf(`hasFormattingDirective(%q) = %v, want %v`, format, got, expected)
+		}
+	}
+}