From d22dd2c3ec61687e3c7130435970abe27fa9e4b2 Mon Sep 17 00:00:00 2001 From: Socialpranker <273312799+Socialpranker@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:13:38 +0200 Subject: [PATCH] tr: align [:upper:]/[:lower:] the way GNU does, and say so --- src/uu/tr/locales/en-US.ftl | 2 +- src/uu/tr/locales/fr-FR.ftl | 2 +- src/uu/tr/src/operation.rs | 50 +++++++++++++++------------ tests/by-util/test_tr.rs | 69 ++++++++++++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 25 deletions(-) diff --git a/src/uu/tr/locales/en-US.ftl b/src/uu/tr/locales/en-US.ftl index c8a6fa6601f..64e6f5640b7 100644 --- a/src/uu/tr/locales/en-US.ftl +++ b/src/uu/tr/locales/en-US.ftl @@ -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 b563485ba17..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 = 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 = 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, diff --git a/src/uu/tr/src/operation.rs b/src/uu/tr/src/operation.rs index 22fd8904719..932903e145f 100644 --- a/src/uu/tr/src/operation.rs +++ b/src/uu/tr/src/operation.rs @@ -385,26 +385,30 @@ 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 (set2_pos, set2_item) in set2.iter().enumerate() { - if matches!(set2_item, Self::Class(_)) { - let set2_part_solved_len = Self::expanded_len_of(&set2[..set2_pos]); - - let mut class_matches = false; - for (set1_pos, set1_item) in set1.iter().enumerate() { - if matches!(set1_item, Self::Class(_)) - && Self::expanded_len_of(&set1[..set1_pos]) == set2_part_solved_len - { - class_matches = true; - break; + // 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. + if translating { + for (set2_pos, set2_item) in set2.iter().enumerate() { + if matches!(set2_item, Self::Class(_)) { + let set2_part_solved_len = Self::expanded_len_of(&set2[..set2_pos]); + + let mut class_matches = false; + for (set1_pos, set1_item) in set1.iter().enumerate() { + // only upper/lower can pair with upper/lower + if matches!(set1_item, Self::Class(Class::Upper | Class::Lower)) + && Self::expanded_len_of(&set1[..set1_pos]) == set2_part_solved_len + { + class_matches = true; + break; + } } - } - if !class_matches { - return Err(SequenceError::whole_set( - BadSequence::ClassInSet2NotMatchedBySet1, - 2, - )); + if !class_matches { + return Err(SequenceError::whole_set( + BadSequence::ClassInSet2NotMatchedBySet1, + 2, + )); + } } } } @@ -449,10 +453,12 @@ impl Sequence { } } // Otherwise set1 is cut to the length of set2 while pairing below. - } 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 2fd477dbf55..426fc9428b5 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -301,6 +301,73 @@ 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) + // No input: the sets are validated before stdin is read, so tr exits + // while the harness is still writing and the write hits a broken pipe. + .pipe_in("") + .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"); +} + +/// -s alone still translates, so the alignment rule still applies. +#[test] +fn test_misaligned_construct_checked_when_squeezing() { + new_ucmd!() + .args(&["-s", "[:alpha:]", "[:upper:]"]) + .pipe_in("") + .fails_with_code(1) + .no_stdout() + .stderr_contains("tr: misaligned [:upper:] and/or [:lower:] construct\n"); +} + +/// Plain -d takes one set: a class in SET2 is an extra operand, not a +/// misaligned construct. +#[test] +fn test_delete_with_class_in_set2() { + new_ucmd!() + .args(&["-d", "[:alpha:]", "[:upper:]"]) + .pipe_in("") + .fails_with_code(1) + .no_stdout() + .stderr_contains("extra operand '[:upper:]'"); +} + +/// 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!() @@ -1610,7 +1677,7 @@ fn test_repeat_lengths_beyond_usize() { "[x*18446744073709551615][:upper:]", ]) .fails() - .stderr_contains("must be matched by"); + .stderr_contains("misaligned [:upper:] and/or [:lower:] construct"); } #[test]