[v22,9/9] posix, argp: Support multiple long option name translations

Message ID 450db6bd8f93d16aa277d771bfebd4118baf3df8.1776957778.git.vivien@planete-kraus.eu (mailing list archive)
State New
Headers
Series Support translated long option names in getopt and argp |

Checks

Context Check Description
redhat-pt-bot/TryBot-apply_patch success Patch applied to master at the time it was sent
redhat-pt-bot/TryBot-32bit success Build for i686

Commit Message

Vivien Kraus April 23, 2026, 4:04 p.m. UTC
  There are programs that have different options having the same
behavior.  For instance, emacs: the --eval and --execute options have
the same behavior.  So, if Emacs were written in a different language
than English where “foo” translates both to “eval” and “execute”, then
the English translator would have a hard time deciding between “eval”
and “execute” for the translation of the only option --foo.

Other example I’ve found: bash --init-file / --rcfile.

In conclusion: the translator lacks the liberty to name equivalent
behaviors differently, and this liberty is sometimes useful.

In this patch, the translation for an option name is expected to be a
space-separated list of equally accepted translations of a
command-line option.  I chose a space because it would be a bad choice
to have a space character in an option name anyway.

The translation type is extended to have a list of translations.  We
change the following behaviors:
1. Argument matching tries any translation;
2. Collision detection searches collisions with all
translations;
3. Argp --help / --usage now lists all translations, but the argument
placeholder is not repeated.
---
 argp/argp-help.c                              | 170 ++++++++++++------
 argp/tst-argphelp-localized.c                 |  55 +++++-
 argp/tst-argphelp-localized.po                |   3 +-
 argp/tst-argpusage-localized.c                |   3 +-
 manual/getopt.texi                            |   5 +
 posix/check-getopt-translations.pl            |  26 +--
 posix/getopt.c                                | 136 ++++++++++++--
 .../standalone-multiple-getopt-collisions.po  |   6 +-
 posix/tst-getopt_long_collision.c             |  10 +-
 posix/tst-getopt_long_collision.po            |   8 +-
 posix/tstgetoptl.c                            |  34 +++-
 posix/tstgetoptl.po                           |   4 +-
 12 files changed, 355 insertions(+), 105 deletions(-)
  

Patch

diff --git a/argp/argp-help.c b/argp/argp-help.c
index aad5d7be13..e748e34258 100644
--- a/argp/argp-help.c
+++ b/argp/argp-help.c
@@ -1205,36 +1205,81 @@  comma (unsigned col, struct pentry_state *pest)
   indent_to (pest->stream, col);
 }
 
-/* Help and usage output show the translated option name.  *allocated
-   holds a pointer that should be freed by the caller, or a NULL
-   pointer.  */
-static const char *
-translate_option_name (const char *name, char **allocated)
+/* Help and usage output show the translated option name.  Since an
+   option name may have multiple translations, we return all of them.
+   The result is to be freed by the caller, each element of the array
+   first, and then the array itself.  */
+static char **
+translate_option_name (const char *name)
 {
   /* Argp does not have a configuration for the context, so a default
      one is used.  */
+  char *msgid = NULL;
+  const char *full_translation = NULL;
+  size_t n_translations;
+  char **results = NULL;
   /* FIXME: use pgettext_expr.  */
-  *allocated = NULL;
   if (__libc_enable_secure)
     /* Translations are disabled.  */
-    return name;
-  if (__asprintf (allocated, "command-line option\004%s", name) == -1)
     {
-      /* *allocated is NULL */
-      return name;
+      results = calloc (2, sizeof (char *));
+      if (results != NULL)
+	{
+	  results[0] = __strdup (name);
+	  if (results[0] == NULL)
+	    {
+	      free (results);
+	      results = NULL;
+	    }
+	}
+      return results;
     }
-  const char *translated = gettext (*allocated);
-  if (strcmp (translated, *allocated) == 0)
+  if (__asprintf (&msgid, "command-line option\004%s", name) == -1)
+    /* Do not bother trying to strdup name.  */
+    return NULL;
+  full_translation = gettext (msgid);
+  if (strcmp (full_translation, msgid) == 0)
+    full_translation = name;
+  /* Split full_translation into results.  Do it in 2 passes: first
+     count, then copy.  */
+  for (int pass = 0; pass < 2; pass++)
     {
-      /* No translation performed.  */
-      free (*allocated);
-      *allocated = NULL;
-      return name;
+      n_translations = 0;
+      const char *start = full_translation;
+      const char *end = NULL;
+      while (start != NULL)
+	{
+	  end = strchr (start, ' ');
+	  if (pass == 1)
+	    {
+	      if (end == NULL)
+		results[n_translations] = __strdup (start);
+	      else
+		results[n_translations] = __strndup (start, end - start);
+	      if (results[n_translations] == NULL)
+		{
+		  /* Abort.  */
+		  for (size_t i = 0; i < n_translations; i++)
+		    free (results[i]);
+		  free (results);
+		  return NULL;
+		}
+	    }
+	  start = end;
+	  if (start != NULL)
+	    /* Skip ' ' */
+	    start++;
+	  n_translations++;
+	}
+      if (pass == 0)
+	results = calloc (n_translations + 1, sizeof (char *));
+      if (results == NULL)
+	return NULL;
     }
-  /* FIXME: is it safe to discard *allocated early here?  Won’t the
-     return value alias it? */
-  /* *allocated is to be freed by the caller.  */
-  return translated;
+  /* We did not touch that index, and allocated the array with
+     calloc.  */
+  assert (results[n_translations] == NULL);
+  return results;
 }
 
 /* Print help for ENTRY to STREAM.  */
@@ -1245,7 +1290,7 @@  hol_entry_help (struct hol_entry *entry, const struct argp_state *state,
   unsigned num;
   const struct argp_option *real = entry->opt, *opt;
   char *so = entry->short_options;
-  const char *translated_option_name;
+  char **translated_option_names;
   int have_long_opt = 0;	/* We have any long options.  */
   /* Saved margins.  */
   int old_lm = __argp_fmtstream_set_lmargin (stream, 0);
@@ -1304,19 +1349,35 @@  hol_entry_help (struct hol_entry *entry, const struct argp_state *state,
   else
     /* A real long option.  */
     {
+      bool needs_untranslated = true;
       __argp_fmtstream_set_wmargin (stream, uparams.long_opt_col);
       for (opt = real, num = entry->num; num > 0; opt++, num--)
 	if (opt->name && ovisible (opt))
 	  {
 	    comma (uparams.long_opt_col, &pest);
-	    char *name_allocated = NULL;
-	    translated_option_name = translate_option_name (opt->name, &name_allocated);
-	    __argp_fmtstream_printf (stream, "--%s", translated_option_name);
-	    arg (real, "=%s", "[=%s]",
-		 state == NULL ? NULL : state->root_argp->argp_domain, stream);
-	    if (strcmp (translated_option_name, opt->name))
+	    translated_option_names = translate_option_name (opt->name);
+	    for (size_t i = 0;
+		 (translated_option_names != NULL
+		  && translated_option_names[i] != NULL);
+		 i++)
+	      {
+		if (i != 0)
+		  __argp_fmtstream_printf (stream, ", ");
+		__argp_fmtstream_printf (stream, "--%s", translated_option_names[i]);
+		/* Only display the argument for the first translation.  */
+		if (i == 0)
+		  arg (real, "=%s", "[=%s]",
+		       state == NULL ? NULL : state->root_argp->argp_domain, stream);
+		/* If we see the untranslated name, we won’t repeat it.  */
+		if (strcmp (translated_option_names[i], opt->name) == 0)
+		  needs_untranslated = false;
+		free (translated_option_names[i]);
+	      }
+	    free (translated_option_names);
+	    if (needs_untranslated)
 	      __argp_fmtstream_printf (stream, " (--%s)", opt->name);
-	    free (name_allocated);
+	    /* If memory allocation failed, the --help output will
+	       just display the untranslated name in parenthesis.  */
 	  }
     }
 
@@ -1458,39 +1519,46 @@  usage_long_opt (const struct argp_option *opt,
 {
   argp_fmtstream_t stream = cookie;
   const char *arg = opt->arg;
-  const char *translated_option_name = opt->name;
+  char **translated_option_names = NULL;
   int flags = opt->flags | real->flags;
+  bool needs_untranslated = true;
 
   if (! arg)
     arg = real->arg;
 
   if (! (flags & OPTION_NO_USAGE))
     {
-      char *name_allocated = NULL;
-      translated_option_name =
-	translate_option_name (opt->name, &name_allocated);
-      int translation_differs =
-	(strcmp (translated_option_name, opt->name) != 0);
+      translated_option_names =
+	translate_option_name (opt->name);
+      __argp_fmtstream_printf (stream, " [");
       if (arg)
+	arg = dgettext (domain, arg);
+      for (size_t i = 0;
+	   (translated_option_names != NULL
+	    && translated_option_names[i] != NULL);
+	   i++)
 	{
-	  arg = dgettext (domain, arg);
-	  if ((flags & OPTION_ARG_OPTIONAL) && translation_differs)
-	    __argp_fmtstream_printf (stream, " [--%s[=%s] (--%s)]",
-				     translated_option_name, arg, opt->name);
-	  else if (flags & OPTION_ARG_OPTIONAL)
-	    __argp_fmtstream_printf (stream, " [--%s[=%s]]", opt->name, arg);
-	  else if (translation_differs)
-	    __argp_fmtstream_printf (stream, " [--%s=%s (--%s)]",
-				     translated_option_name, arg, opt->name);
-	  else
-	    __argp_fmtstream_printf (stream, " [--%s=%s]", opt->name, arg);
+	  if (i != 0)
+	    __argp_fmtstream_printf (stream, " / ");
+	  __argp_fmtstream_printf (stream, "--%s", translated_option_names[i]);
+	  if (arg && i == 0)
+	    {
+	      if (flags & OPTION_ARG_OPTIONAL)
+		__argp_fmtstream_printf (stream, "[=%s]", arg);
+	      else
+		__argp_fmtstream_printf (stream, "=%s", arg);
+	    }
+	  if (strcmp (translated_option_names[i], opt->name) == 0)
+	    needs_untranslated = false;
+	  free (translated_option_names[i]);
 	}
-      else if (translation_differs)
-	__argp_fmtstream_printf (stream, " [--%s (--%s)]",
-				 translated_option_name, opt->name);
-      else
-	__argp_fmtstream_printf (stream, " [--%s]", opt->name);
-      free (name_allocated);
+      free (translated_option_names);
+      if (needs_untranslated)
+	__argp_fmtstream_printf (stream, " (--%s)", opt->name);
+      __argp_fmtstream_printf (stream, "]");
+      /* If memory allocation failed, the output will be like
+	 [ (--option)]
+      */
     }
 
   return 0;
diff --git a/argp/tst-argphelp-localized.c b/argp/tst-argphelp-localized.c
index 8703742b8f..6c36eb9cf8 100644
--- a/argp/tst-argphelp-localized.c
+++ b/argp/tst-argphelp-localized.c
@@ -44,6 +44,8 @@  const struct argp_option options[] =
 };
 
 static bool color_set = false;
+static bool flavor_set = false;
+static bool texture_set = false;
 
 static error_t
 parse_opt (int key, char *arg, struct argp_state *state)
@@ -53,6 +55,14 @@  parse_opt (int key, char *arg, struct argp_state *state)
     FAIL ("color already set.\n");
   else if (key == 'c')
     color_set = true;
+  else if (key == 'f' && flavor_set)
+    FAIL ("flavor already set.\n");
+  else if (key == 'f')
+    flavor_set = true;
+  else if (key == 't' && texture_set)
+    FAIL ("texture already set.\n");
+  else if (key == 't')
+    texture_set = true;
   return 0;
 }
 
@@ -64,6 +74,16 @@  do_test (void)
   char *test1_argv[3] =
     { (char *) "/bin/tst-argphelp-localized", (char *) "--colour=yellow", NULL };
   char *test2_argv[3] =
+    { (char *) "/bin/tst-argphelp-localized", (char *) "--color=yellow", NULL };
+  char *test3_argv[3] =
+    { (char *) "/bin/tst-argphelp-localized", (char *) "--coolur=yellow", NULL };
+  char *test4_argv[3] =
+    { (char *) "/bin/tst-argphelp-localized", (char *) "--flavour", NULL };
+  char *test5_argv[3] =
+    { (char *) "/bin/tst-argphelp-localized", (char *) "--flavor", NULL };
+  char *test6_argv[3] =
+    { (char *) "/bin/tst-argphelp-localized", (char *) "--texture", NULL };
+  char *test7_argv[3] =
     { (char *) "/bin/tst-argphelp-localized", (char *) "--help", NULL };
 
   unsetenv ("LANGUAGE");
@@ -72,18 +92,47 @@  do_test (void)
 				    OBJPFX "domaindir") != NULL);
   TEST_VERIFY_EXIT (textdomain ("tst-argphelp-localized") != NULL);
   /* Check that the catalog is OK: */
-  TEST_COMPARE_STRING (gettext ("command-line option\004color"), "colour");
+  TEST_COMPARE_STRING (gettext ("command-line option\004color"),
+		       "colour coolur");
   TEST_COMPARE_STRING (gettext ("COOKIE"), "BISCUIT");
   argp_parse (&argp, 2, test1_argv, 0, 0, NULL);
   TEST_VERIFY (color_set);
+  TEST_VERIFY (!flavor_set);
+  TEST_VERIFY (!texture_set);
   color_set = false;
+  argp_parse (&argp, 2, test2_argv, 0, 0, NULL);
+  TEST_VERIFY (color_set);
+  TEST_VERIFY (!flavor_set);
+  TEST_VERIFY (!texture_set);
+  color_set = false;
+  argp_parse (&argp, 2, test3_argv, 0, 0, NULL);
+  TEST_VERIFY (color_set);
+  TEST_VERIFY (!flavor_set);
+  TEST_VERIFY (!texture_set);
+  color_set = false;
+  argp_parse (&argp, 2, test4_argv, 0, 0, NULL);
+  TEST_VERIFY (!color_set);
+  TEST_VERIFY (flavor_set);
+  TEST_VERIFY (!texture_set);
+  flavor_set = false;
+  argp_parse (&argp, 2, test5_argv, 0, 0, NULL);
+  TEST_VERIFY (!color_set);
+  TEST_VERIFY (flavor_set);
+  TEST_VERIFY (!texture_set);
+  flavor_set = false;
+  argp_parse (&argp, 2, test6_argv, 0, 0, NULL);
+  TEST_VERIFY (!color_set);
+  TEST_VERIFY (!flavor_set);
+  TEST_VERIFY (texture_set);
+  texture_set = false;
 
   /* This is the last chance to fail.  */
   if (support_record_failure_is_failed ())
-    FAIL_EXIT1 ("There were test failures before the final invocation of --help");
+    FAIL_EXIT1 (
+	"There were test failures before the final invocation of --help");
   /* This last test will exit the program with code 0 and ignore
      previous failures.  */
-  argp_parse (&argp, 2, test2_argv, 0, 0, NULL);
+  argp_parse (&argp, 2, test7_argv, 0, 0, NULL);
   FAIL_EXIT1 ("--help did not exit the program");
   return 0;
 }
diff --git a/argp/tst-argphelp-localized.po b/argp/tst-argphelp-localized.po
index 718cb39ab7..5b3a672a6d 100644
--- a/argp/tst-argphelp-localized.po
+++ b/argp/tst-argphelp-localized.po
@@ -15,7 +15,7 @@  msgstr ""
 #: tst-argphelp-localized.c:73
 msgctxt "command-line option"
 msgid "color"
-msgstr "colour"
+msgstr "colour coolur"
 
 msgctxt "command-line option"
 msgid "flavor"
@@ -23,3 +23,4 @@  msgstr "flavour"
 
 msgid "COOKIE"
 msgstr "BISCUIT"
+
diff --git a/argp/tst-argpusage-localized.c b/argp/tst-argpusage-localized.c
index f93c2a156e..5d1f568679 100644
--- a/argp/tst-argpusage-localized.c
+++ b/argp/tst-argpusage-localized.c
@@ -64,7 +64,8 @@  do_test (void)
 				    OBJPFX "domaindir") != NULL);
   TEST_VERIFY_EXIT (textdomain ("tst-argphelp-localized") != NULL);
   /* Check that the catalog is OK: */
-  TEST_COMPARE_STRING (gettext ("command-line option\004color"), "colour");
+  TEST_COMPARE_STRING (gettext ("command-line option\004color"),
+		       "colour coolur");
   TEST_COMPARE_STRING (gettext ("COOKIE"), "BISCUIT");
   /* This is the last chance to fail.  */
   if (support_record_failure_is_failed ())
diff --git a/manual/getopt.texi b/manual/getopt.texi
index e39f3e3f85..e7216c2baf 100644
--- a/manual/getopt.texi
+++ b/manual/getopt.texi
@@ -257,6 +257,11 @@  invocation of your program, the program users should be encouraged to
 use untranslated option names or publish the locale used for this
 invocation.
 
+If the translation of an option name contains a space character, then
+it means multiple translations recognize the same option name.  This
+is useful to upgrade a translation without disrupting the user's
+workflow.
+
 Since option names may be short words instead of long sentences, they
 may have different translations in different contexts within the same
 program.  @xref{Contexts, , Using contexts for solving ambiguities,
diff --git a/posix/check-getopt-translations.pl b/posix/check-getopt-translations.pl
index c3c3cff1eb..5cabd9be31 100644
--- a/posix/check-getopt-translations.pl
+++ b/posix/check-getopt-translations.pl
@@ -146,7 +146,8 @@  while (my $line = <$pofile>) {
     } elsif ($parser_state == 3 && $line eq "") {
         $parser_state = 1;
     } elsif ($parser_state == 3 && $line =~ /^msgstr\s*"([^"]*)"$/) {
-        $translations{$entry_msgid} = $1;
+        my @translations_for_this = split(/\s+/, $1);
+        $translations{$entry_msgid} = \@translations_for_this;
         $parser_state = 1;
     }
 }
@@ -156,13 +157,14 @@  my $number_of_errors = 0;
 # Verify that every option name is unique.
 my %untranslated_name;
 for my $option_name (sort(keys %translations)) {
-    my $translation = $translations{$option_name};
-    my @existing;
-    if (exists $untranslated_name{$translation}) {
-	@existing = @{$untranslated_name{$translation}};
+    for my $translation (@{$translations{$option_name}}) {
+        my @existing;
+        if (exists $untranslated_name{$translation}) {
+            @existing = @{$untranslated_name{$translation}};
+        }
+        push(@existing, $option_name);
+        $untranslated_name{$translation} = \@existing;
     }
-    push(@existing, $option_name);
-    $untranslated_name{$translation} = \@existing;
 }
 for my $translation (sort(keys %untranslated_name)) {
     my $names = $untranslated_name{$translation};
@@ -180,10 +182,12 @@  for my $translation (sort(keys %untranslated_name)) {
 for my $option_name (sort(keys %translations)) {
     for my $other_option_name (sort(keys %translations)) {
         if ($option_name ne $other_option_name) {
-	    if ($translations{$option_name} eq $other_option_name) {
-		print STDERR "${translations{$option_name}} is a translation of ${option_name}, but it is also a different option.\n";
-		++$number_of_errors;
-	    }
+            for my $translation (@{$translations{$option_name}}) {
+                if ($translation eq $other_option_name) {
+                    print STDERR "${translation} is a translation of ${option_name}, but it is also a different option.\n";
+                    ++$number_of_errors;
+                }
+            }
         }
     }
 }
diff --git a/posix/getopt.c b/posix/getopt.c
index 2983fe6ea7..32cd4cf120 100644
--- a/posix/getopt.c
+++ b/posix/getopt.c
@@ -182,6 +182,50 @@  exchange (char **argv, struct _getopt_data *d)
   d->__last_nonopt = d->optind;
 }
 
+/* Return TRUE if other is equal to reference.  */
+static bool
+complete_match (const char *reference,
+		size_t reference_length,
+		const char *other)
+{
+  return (strncmp (reference, other, reference_length) == 0
+	  /* So, reference is a prefix of other */
+	  && other[reference_length] == '\0');
+}
+
+/* Match a string against a space-separated string list.  We save an
+   allocated copy of the exact match for the collision checker.  */
+static bool
+match_any_translation (const char *reference,
+		       size_t reference_length,
+		       const char *translation,
+		       char **match)
+{
+  const char *start = translation;
+  const char *end;
+
+  if (match)
+    *match = NULL;
+  while (start != NULL)
+    {
+      end = strchr (start, ' ');
+      if ((end == NULL && complete_match (reference, reference_length, start))
+	  || (end != NULL
+	      && end - start == reference_length
+	      && strncmp (reference, start, reference_length) == 0))
+	{
+	  if (match)
+	    *match = __strndup (reference, reference_length);
+	  return true;
+	}
+      start = end;
+      if (start != NULL)
+	/* Skip the space character.  */
+	start++;
+    }
+  return false;
+}
+
 /* Return true iff translation_context is not NULL, a translation for
    opt_name has been found and it matches the substring from argument,
    length argument_length.
@@ -202,16 +246,42 @@  match_translated_option_name (char *(*translate) (const char *, const char *,
   if (translate != NULL && !__libc_enable_secure)
     translated = translate (opt_textdomain, translation_context,
 			    opt_name, &translation_buffer);
-
-  if (strncmp (translated, argument, argument_length) != 0)
-    matches = false;
-  else
-    /* We know that argument is a prefix of translated.  */
-    matches = translated[argument_length] == '\0';
+  matches = match_any_translation (argument, argument_length, translated, NULL);
   free (translation_buffer);
   return matches;
 }
 
+/* Translate opt_name, but only keep the first item of the list.  This
+   is used for error messages.  */
+static const char *
+first_translation (char *(*translate) (const char *, const char *,
+				       const char *, char **),
+		   const char *translation_context,
+		   const char *opt_textdomain,
+		   const char *opt_name,
+		   char **allocated)
+{
+  char *translation_buffer = NULL;
+  const char *all_translations =
+    translate (opt_textdomain, translation_context, opt_name,
+	       &translation_buffer);
+  const char *end = strchr (all_translations, ' ');
+  if (end == NULL)
+    {
+      /* There is only 1 translation for opt_name.  No extra
+	 processing is needed.  */
+      *allocated = translation_buffer;
+      return all_translations;
+    }
+  *allocated = __strndup (all_translations, end - all_translations);
+  free (translation_buffer);
+  if (*allocated == NULL)
+    /* Memory allocation failed; return opt_name.  No extra memory
+       needs to be kept around.  */
+    return opt_name;
+  return *allocated;
+}
+
 /* Process the argument starting with d->__nextchar as a long option.
    d->optind should *not* have been advanced over this argument.
 
@@ -393,9 +463,9 @@  process_long_option (int argc, char **argv, const char *optstring,
 	{
 	  if (print_errors)
 	    {
-	      translated_option_name = translate (d->opttextdomain, d->optctxt,
-						  pfound->name,
-						  &translation_buffer);
+	      translated_option_name =
+		first_translation (translate, d->optctxt, d->opttextdomain,
+				   pfound->name, &translation_buffer);
 	      if (strcmp (translated_option_name, pfound->name) != 0)
 		/* Print both names of the option.  */
 		fprintf (stderr,
@@ -423,9 +493,9 @@  process_long_option (int argc, char **argv, const char *optstring,
 	    {
 	      /* Same dichotomy as when the option does not allow an
 		 argument.  */
-	      translated_option_name = translate (d->opttextdomain, d->optctxt,
-						  pfound->name,
-						  &translation_buffer);
+	      translated_option_name =
+		first_translation (translate, d->optctxt, d->opttextdomain,
+				   pfound->name, &translation_buffer);
 	      if (strcmp (translated_option_name, pfound->name) != 0)
 		fprintf (stderr,
 			 _("%s: option '%s%s' / '%s%s' requires an argument\n"),
@@ -488,6 +558,32 @@  _getopt_initialize (_GL_UNUSED int argc,
 }
 
 
+/* Match any item of a string list against any item of another.  This
+   is used by the collision checker.  */
+static bool
+match_any_translation_pair (const char *list_a,
+			    const char *list_b,
+			    char **match)
+{
+  const char *start = list_a;
+  const char *end;
+
+  while (start != NULL)
+    {
+      end = strchr (start, ' ');
+      if ((end == NULL
+	   && match_any_translation (start, strlen (start), list_b, match))
+	  || (end != NULL
+	      && match_any_translation (start, end - start, list_b, match)))
+	return true;
+      start = end;
+      if (start != NULL)
+	/* Skip the space character.  */
+	start++;
+    }
+  return false;
+}
+
 static bool
 has_translation_collisions (const char *domain,
 			    const char *context,
@@ -508,6 +604,7 @@  has_translation_collisions (const char *domain,
   char *b_buffer = NULL;
   const char *b_name = NULL;
   const struct option *option_b;
+  char *collision = NULL;
   bool has_collision = false;
 
   if (do_translate == NULL || context == NULL)
@@ -532,7 +629,9 @@  has_translation_collisions (const char *domain,
 	  {
 	    option_b = &(long_options[option_index_b]);
 	    b_name = do_translate (domain, context, option_b->name, &b_buffer);
-	    if (strcmp (option_a->name, b_name) == 0)
+	    collision = NULL;
+	    if (match_any_translation (option_a->name, strlen (option_a->name), b_name,
+				       &collision))
 	      {
 		if (print_errors)
 		  /* Since we do not consider a particular use of an
@@ -546,13 +645,15 @@  has_translation_collisions (const char *domain,
 			   argv0,
 			   domain, context,
 			   option_a->name,
-			   option_b->name, b_name);
+			   option_b->name, collision);
 		has_collision = true;
 	      }
-	    if (strcmp (a_name, b_name) == 0
+	    free (collision);
+	    collision = NULL;
+	    if (option_index_a < option_index_b
 		&& strcmp (option_a->name, a_name) != 0
 		&& strcmp (option_b->name, b_name) != 0
-		&& option_index_a < option_index_b)
+		&& match_any_translation_pair (a_name, b_name, &collision))
 	      {
 		if (print_errors)
 		  fprintf (stderr,
@@ -560,9 +661,10 @@  has_translation_collisions (const char *domain,
 			     "domain '%s', context '%s': "
 			     "both '%s' and '%s' translate to '%s'\n"),
 			   argv0, domain, context,
-			   option_a->name, option_b->name, a_name);
+			   option_a->name, option_b->name, collision);
 		has_collision = true;
 	      }
+	    free (collision);
 	    free (b_buffer);
 	  }
       free (a_buffer);
diff --git a/posix/standalone-multiple-getopt-collisions.po b/posix/standalone-multiple-getopt-collisions.po
index 14b876a2a3..edd2231d8f 100644
--- a/posix/standalone-multiple-getopt-collisions.po
+++ b/posix/standalone-multiple-getopt-collisions.po
@@ -27,17 +27,17 @@  msgstr "bar"
 # This is the --foo option.
 msgctxt "command-line option"
 msgid "foo"
-msgstr "toto"
+msgstr "tata toto"
 
 # This is the --bar option.  Oops, I translated with toto here too.
 msgctxt "command-line option"
 msgid "bar"
-msgstr "toto"
+msgstr "titi toto"
 
 # Let’s go to the --pub!
 msgctxt "command-line option"
 msgid "pub"
-msgstr "bar"
+msgstr "bar club"
 
 # Wait, it’s OK if baz is translated to baz though.
 msgctxt "command-line option"
diff --git a/posix/tst-getopt_long_collision.c b/posix/tst-getopt_long_collision.c
index 2e603ec9f8..ff90ec1131 100644
--- a/posix/tst-getopt_long_collision.c
+++ b/posix/tst-getopt_long_collision.c
@@ -42,6 +42,8 @@ 
    In the third, we don’t translate anything:
    foo -> foo
    bar -> bar
+
+   For added fun, we add some noise to the translations.
   */
 
 static const struct option options[] =
@@ -62,13 +64,13 @@  setup_catalog (void)
   TEST_VERIFY_EXIT (textdomain ("tst-getopt_long_collision") != NULL);
   /* Check that the catalog is OK: */
   TEST_COMPARE_STRING (dgettext ("tst-getopt_long_collision", "kind 1\004foo"),
-		       "bar");
+		       "bar noise1");
   TEST_COMPARE_STRING (dgettext ("tst-getopt_long_collision", "kind 1\004bar"),
-		       "baz");
+		       "noise2 baz noise3");
   TEST_COMPARE_STRING (dgettext ("tst-getopt_long_collision", "kind 2\004foo"),
-		       "same");
+		       "same noise4");
   TEST_COMPARE_STRING (dgettext ("tst-getopt_long_collision", "kind 2\004bar"),
-		       "same");
+		       "noise5 same");
   TEST_COMPARE_STRING (dgettext ("tst-getopt_long_collision", "kind 3\004foo"),
 		       "kind 3\004foo");
   TEST_COMPARE_STRING (dgettext ("tst-getopt_long_collision", "kind 3\004bar"),
diff --git a/posix/tst-getopt_long_collision.po b/posix/tst-getopt_long_collision.po
index 2f39001c6d..a196e81f38 100644
--- a/posix/tst-getopt_long_collision.po
+++ b/posix/tst-getopt_long_collision.po
@@ -17,16 +17,16 @@  msgstr ""
 
 msgctxt "kind 1"
 msgid "foo"
-msgstr "bar"
+msgstr "bar noise1"
 
 msgctxt "kind 1"
 msgid "bar"
-msgstr "baz"
+msgstr "noise2 baz noise3"
 
 msgctxt "kind 2"
 msgid "foo"
-msgstr "same"
+msgstr "same noise4"
 
 msgctxt "kind 2"
 msgid "bar"
-msgstr "same"
+msgstr "noise5 same"
diff --git a/posix/tstgetoptl.c b/posix/tstgetoptl.c
index bdc20b7e3d..0374219dc5 100644
--- a/posix/tstgetoptl.c
+++ b/posix/tstgetoptl.c
@@ -31,10 +31,27 @@ 
    This echoes tstgetopt.c, where --colour was an option name alias
    for --color, so it had to be listed twice.  */
 
-/* This uses the en_GB locale so that colour means color.  We also
-   check that getopt only matches translations for actual options, by
-   having the user pass --flavour (which is a known translation of
-   flavor) without the program recognizing a --flavor option.  */
+/* This uses the en_GB locale so that colour means color.
+
+   Oh no! The translator made a mistake and translated color as
+   “coolur”.  A bug-fix has been released, but in the mean time, a
+   popular British influencer made a blog post about how you can use
+   “coolur” and it turned into a meme.  Lots of people have
+   copy-pasted a custom script to check if the program supports
+   British values, and it does so by checking whether --coolur prints
+   “as red as a sunburnt tourist”.  To the point where ChatGPT and
+   other LLMs now consider this the pinnacle of British
+   exceptionalism.  The UK MPs have voted to make the script a
+   mandatory part of any operating systems used by British people
+   anywhere in the world, with severe fines for anyone not using the
+   exact version prescribed by the law.  It has thus been decided to
+   support both “colour” and “coolur”, without creating a new option
+   (only “color” exists for the developers).
+
+   We also check that getopt only matches
+   translations for actual options, by having the user pass --flavour
+   (which is a known translation of flavor) without the program
+   recognizing a --flavor option.  */
 
 #define TRANSLATION_CONTEXT "command-line option"
 
@@ -48,7 +65,7 @@  prepare_localedir (void)
   /* Check that the catalog is OK: */
   TEST_COMPARE_STRING (dgettext ("tstgetoptl",
 				 TRANSLATION_CONTEXT "\004" "color"),
-		       "colour");
+		       "colour coolur");
   TEST_COMPARE_STRING (dgettext ("tstgetoptl",
 				 TRANSLATION_CONTEXT "\004" "flavor"),
 		       "flavour");
@@ -61,7 +78,7 @@  prepare_argv (int *argc)
     {
       (char *) "tstgetoptl", (char *) "--required", (char *) "foobar",
       (char *) "--optional=bazbug", (char *) "--col", (char *) "--color",
-      (char *) "--colour", (char *) "--flavour", NULL
+      (char *) "--colour", (char *) "--coolur", (char *) "--flavour", NULL
     };
   *argc = array_length (argv) - 1;
   return argv;
@@ -79,7 +96,8 @@  do_my_test (bool with_optctxt)
       {"required", required_argument, NULL, 'r'},
       {"optional", optional_argument, NULL, 'o'},
       {"color",	   no_argument,	      NULL, 'C'},
-      /* Now colour is handled as a translation of color.  */
+      /* Now colour (and coolur) are handled as a translation of
+	 color.  */
       /* Note that there’s no "--flavor" option, so the "flavor" ->
 	 "flavour" translation is useless.  */
       {NULL, 0, NULL, 0 }
@@ -139,7 +157,7 @@  do_my_test (bool with_optctxt)
   printf ("Cflags = %d\n", Cflag);
 
   if (with_optctxt)
-    TEST_COMPARE (Cflag, 3);
+    TEST_COMPARE (Cflag, 4);
   else
     TEST_COMPARE (Cflag, 2);
 
diff --git a/posix/tstgetoptl.po b/posix/tstgetoptl.po
index b1dc11c468..341ac9ea33 100644
--- a/posix/tstgetoptl.po
+++ b/posix/tstgetoptl.po
@@ -7,7 +7,7 @@  msgstr ""
 "Project-Id-Version: tstgetoptl 0.0.0\n"
 "Report-Msgid-Bugs-To: \n"
 "POT-Creation-Date: 2025-05-27 19:29+0200\n"
-"PO-Revision-Date: 2025-05-27 19:30+0200\n"
+"PO-Revision-Date: 2025-06-06 21:22+0200\n"
 "Language-Team: English (British) <(nothing)>\n"
 "Language: en_GB\n"
 "MIME-Version: 1.0\n"
@@ -18,7 +18,7 @@  msgstr ""
 #: xxx.c:yy
 msgctxt "command-line option"
 msgid "color"
-msgstr "colour"
+msgstr "colour coolur"
 
 #: xxx.c:yy
 msgctxt "command-line option"