From abcbcda1e57ddc2f4db87141550fef01932bae1b Mon Sep 17 00:00:00 2001 From: Socialpranker <0630039863abc@gmail.com> Date: Sat, 5 Sep 2026 19:35:34 +0200 Subject: [PATCH 1/3] tr: align [:upper:]/[:lower:] the way GNU does, and say so Three related gaps in the SET1/SET2 class rules: - Any class in SET1 counted as a match for an 'upper'/'lower' in SET2, so 'tr [:alpha:] [:upper:]' fell through to the length check and 'tr [:digit:] [:upper:]' was accepted and translated nothing. Only an 'upper'/'lower' can line up with one. - Both class checks ran with -d, where SET2 is the squeeze set and lines up with nothing: 'tr -ds [:alpha:] [:upper:]' failed instead of deleting. - The diagnostic named the rule instead of the fault; GNU calls it a misaligned construct. Also the second line of the -ds missing-operand message, which GNU spells 'when both deleting and squeezing repeats'. --- src/uu/tr/locales/en-US.ftl | 4 ++-- src/uu/tr/locales/fr-FR.ftl | 4 ++-- src/uu/tr/src/operation.rs | 20 +++++++++++------ tests/by-util/test_tr.rs | 44 ++++++++++++++++++++++++++++++++++++- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/uu/tr/locales/en-US.ftl b/src/uu/tr/locales/en-US.ftl index 5203bc22623..64e6f5640b7 100644 --- a/src/uu/tr/locales/en-US.ftl +++ b/src/uu/tr/locales/en-US.ftl @@ -13,7 +13,7 @@ tr-error-missing-operand = missing operand tr-error-missing-operand-translating = missing operand after { $set } Two strings must be given when translating. tr-error-missing-operand-deleting-squeezing = missing operand after { $set } - Two strings must be given when deleting and squeezing. + Two strings must be given when both deleting and squeezing repeats. tr-error-extra-operand-deleting-without-squeezing = extra operand { $operand } Only one string may be given when deleting without squeezing repeats. tr-error-extra-operand-simple = extra operand { $operand } @@ -34,7 +34,7 @@ tr-error-char-repeat-in-set1 = the [c*] repeat construct may not appear in strin tr-error-invalid-repeat-count = invalid repeat count { $count } in [c*n] construct tr-error-empty-set2-when-not-truncating = when not truncating set1, string2 must be non-empty tr-error-class-except-lower-upper-in-set2 = when translating, the only character classes that may appear in set2 are 'upper' and 'lower' -tr-error-class-in-set2-not-matched = when translating, every 'upper'/'lower' in set2 must be matched by a 'upper'/'lower' in the same position in set1 +tr-error-class-in-set2-not-matched = misaligned [:upper:] and/or [:lower:] construct tr-error-set1-longer-set2-ends-in-class = when translating with string1 longer than string2, the latter string must not end with a character class tr-error-complement-more-than-one-unique = when translating with complemented character classes, diff --git a/src/uu/tr/locales/fr-FR.ftl b/src/uu/tr/locales/fr-FR.ftl index 31deed0ede3..d1cff5553ec 100644 --- a/src/uu/tr/locales/fr-FR.ftl +++ b/src/uu/tr/locales/fr-FR.ftl @@ -13,7 +13,7 @@ tr-error-missing-operand = opérande manquant tr-error-missing-operand-translating = opérande manquant après { $set } Deux chaînes doivent être données lors de la traduction. tr-error-missing-operand-deleting-squeezing = opérande manquant après { $set } - Deux chaînes doivent être données lors de la suppression et compression. + Deux chaînes doivent être données lors de la suppression et de la compression des répétitions. tr-error-extra-operand-deleting-without-squeezing = opérande supplémentaire { $operand } Une seule chaîne peut être donnée lors de la suppression sans compression des répétitions. tr-error-extra-operand-simple = opérande supplémentaire { $operand } @@ -35,7 +35,7 @@ tr-error-char-repeat-in-set1 = la construction de répétition [c*] ne peut pas tr-error-invalid-repeat-count = nombre de répétitions invalide { $count } dans la construction [c*n] tr-error-empty-set2-when-not-truncating = quand on ne tronque pas set1, string2 doit être non-vide tr-error-class-except-lower-upper-in-set2 = lors de la traduction, les seules classes de caractères qui peuvent apparaître dans set2 sont 'upper' et 'lower' -tr-error-class-in-set2-not-matched = lors de la traduction, chaque 'upper'/'lower' dans set2 doit être associé à un 'upper'/'lower' à la même position dans set1 +tr-error-class-in-set2-not-matched = construction [:upper:] et/ou [:lower:] mal alignée tr-error-set1-longer-set2-ends-in-class = lors de la traduction avec string1 plus long que string2, cette dernière chaîne ne doit pas se terminer par une classe de caractères tr-error-complement-more-than-one-unique = lors de la traduction avec des classes de caractères complémentées, diff --git a/src/uu/tr/src/operation.rs b/src/uu/tr/src/operation.rs index 8c503490ff2..3c12ea36768 100644 --- a/src/uu/tr/src/operation.rs +++ b/src/uu/tr/src/operation.rs @@ -318,9 +318,10 @@ impl Sequence { }) .collect(); - // For every upper/lower in set2, there must be an upper/lower in set1 at the same position. The position is calculated by expanding everything before the upper/lower in both sets + // For every upper/lower in set2, there must be an upper/lower in set1 at the same position. The position is calculated by expanding everything before the upper/lower in both sets. + // Only when translating: with -d, set2 is the squeeze set and lines up with nothing. for (set2_pos, set2_item) in set2.iter().enumerate() { - if matches!(set2_item, Self::Class(_)) { + if translating && matches!(set2_item, Self::Class(_)) { let mut set2_part_solved_len = 0; if set2_pos >= 1 { set2_part_solved_len = @@ -329,7 +330,10 @@ impl Sequence { let mut class_matches = false; for (set1_pos, set1_item) in set1.iter().enumerate() { - if matches!(set1_item, Self::Class(_)) { + // Only an 'upper'/'lower' in set1 can line up with one in + // set2: any other class there leaves the mapping + // undefined, so GNU rejects it. + if matches!(set1_item, Self::Class(Class::Upper | Class::Lower)) { let mut set1_part_solved_len = 0; if set1_pos >= 1 { set1_part_solved_len = @@ -401,10 +405,12 @@ impl Sequence { } else { set1_solved.truncate(set2_solved.len()); } - } else if matches!( - set2.last().copied(), - Some(Self::Class(Class::Upper | Class::Lower)) - ) { + } else if translating + && matches!( + set2.last().copied(), + Some(Self::Class(Class::Upper | Class::Lower)) + ) + { return Err(SequenceError::whole_set( BadSequence::Set1LongerSet2EndsInClass, 1, diff --git a/tests/by-util/test_tr.rs b/tests/by-util/test_tr.rs index 493fb1916a8..94fcfaac581 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -299,13 +299,55 @@ fn test_translate_and_squeeze_multiple_lines() { .stdout_is("yaay\nyaay"); // spell-checker:disable-line } +/// An 'upper'/'lower' in SET2 must line up with an 'upper'/'lower' in SET1; +/// any other class there leaves the mapping undefined. GNU calls that a +/// misaligned construct. +#[test] +fn test_misaligned_upper_lower_construct() { + for sets in [ + ["[:alpha:]", "[:upper:]"], + ["[:digit:]", "[:upper:]"], + ["abc", "[:upper:]"], + ["a[:lower:]", "[:upper:]b"], + ["[:lower:]", "[:upper:][:lower:]"], + ] { + new_ucmd!() + .args(&sets) + .pipe_in("aZ1") + .fails_with_code(1) + .no_stdout() + .stderr_contains("tr: misaligned [:upper:] and/or [:lower:] construct\n"); + } +} + +/// The alignment rule is about translating only: with -d, SET2 is the squeeze +/// set and lines up with nothing. +#[test] +fn test_misaligned_construct_not_checked_when_deleting() { + new_ucmd!() + .args(&["-ds", "[:alpha:]", "[:upper:]"]) + .pipe_in("aZ1") + .succeeds() + .stdout_is("1"); +} + +/// SET1 longer than SET2 keeps its own message, which GNU also has. +#[test] +fn test_set1_longer_than_set2_ending_in_class() { + new_ucmd!() + .args(&["[:upper:][:lower:]", "[:lower:]"]) + .pipe_in("aZ1") + .fails_with_code(1) + .stderr_contains("when translating with string1 longer than string2,\n"); +} + #[test] fn test_delete_and_squeeze_one_set() { new_ucmd!() .args(&["-ds", "a-z"]) .fails() .stderr_contains("missing operand after 'a-z'") - .stderr_contains("Two strings must be given when deleting and squeezing."); + .stderr_contains("Two strings must be given when both deleting and squeezing repeats."); } #[test] From 828738d3a75ed6a4e462ee63d62bc6807641c6ef Mon Sep 17 00:00:00 2001 From: Socialpranker <273312799+Socialpranker@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:24:36 +0200 Subject: [PATCH 2/3] tr: align fr-FR translation with GNU's official po for misaligned upper/lower MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GNU's translationproject.org fr.po translates 'misaligned [:upper:] and/or [:lower:] construct' as 'les structures [:upper:] ou [:lower:] sont mal alignées'; match that wording instead of the ad hoc phrasing. --- src/uu/tr/locales/fr-FR.ftl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/tr/locales/fr-FR.ftl b/src/uu/tr/locales/fr-FR.ftl index d1cff5553ec..1fbea8a2443 100644 --- a/src/uu/tr/locales/fr-FR.ftl +++ b/src/uu/tr/locales/fr-FR.ftl @@ -35,7 +35,7 @@ tr-error-char-repeat-in-set1 = la construction de répétition [c*] ne peut pas tr-error-invalid-repeat-count = nombre de répétitions invalide { $count } dans la construction [c*n] tr-error-empty-set2-when-not-truncating = quand on ne tronque pas set1, string2 doit être non-vide tr-error-class-except-lower-upper-in-set2 = lors de la traduction, les seules classes de caractères qui peuvent apparaître dans set2 sont 'upper' et 'lower' -tr-error-class-in-set2-not-matched = construction [:upper:] et/ou [:lower:] mal alignée +tr-error-class-in-set2-not-matched = les structures [:upper:] ou [:lower:] sont mal alignées tr-error-set1-longer-set2-ends-in-class = lors de la traduction avec string1 plus long que string2, cette dernière chaîne ne doit pas se terminer par une classe de caractères tr-error-complement-more-than-one-unique = lors de la traduction avec des classes de caractères complémentées, From f773c6b0a67e0f9309e8d1afe56320b9843d6c65 Mon Sep 17 00:00:00 2001 From: Socialpranker <273312799+Socialpranker@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:27:38 +0200 Subject: [PATCH 3/3] tr: reword the fr-FR misaligned-classes message --- src/uu/tr/locales/fr-FR.ftl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/tr/locales/fr-FR.ftl b/src/uu/tr/locales/fr-FR.ftl index 1fbea8a2443..591f002c366 100644 --- a/src/uu/tr/locales/fr-FR.ftl +++ b/src/uu/tr/locales/fr-FR.ftl @@ -35,7 +35,7 @@ tr-error-char-repeat-in-set1 = la construction de répétition [c*] ne peut pas tr-error-invalid-repeat-count = nombre de répétitions invalide { $count } dans la construction [c*n] tr-error-empty-set2-when-not-truncating = quand on ne tronque pas set1, string2 doit être non-vide tr-error-class-except-lower-upper-in-set2 = lors de la traduction, les seules classes de caractères qui peuvent apparaître dans set2 sont 'upper' et 'lower' -tr-error-class-in-set2-not-matched = les structures [:upper:] ou [:lower:] sont mal alignées +tr-error-class-in-set2-not-matched = les structures [:upper:] ou [:lower:] ne sont pas alignées correctement tr-error-set1-longer-set2-ends-in-class = lors de la traduction avec string1 plus long que string2, cette dernière chaîne ne doit pas se terminer par une classe de caractères tr-error-complement-more-than-one-unique = lors de la traduction avec des classes de caractères complémentées,