From 60e7343db74f3e55abf9ee517b6c4dfa7d2de958 Mon Sep 17 00:00:00 2001 From: Yunus Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:05:30 +0300 Subject: [PATCH 1/5] tr: reject [c*] in set2 unless translating and [=c=] when translating [=c=] was parsed as a plain character, so nothing could tell it apart in set2. Keep it as its own sequence, and reject it in set2 when translating, where only -d -s may use it. Reject [c*] in set2 when not translating, since there is no set1 length for it to fill. --- src/uu/tr/locales/en-US.ftl | 2 ++ src/uu/tr/locales/fr-FR.ftl | 2 ++ src/uu/tr/src/diagnostics.rs | 2 ++ src/uu/tr/src/operation.rs | 39 +++++++++++++++++++++++++++--- tests/by-util/test_tr.rs | 46 ++++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/uu/tr/locales/en-US.ftl b/src/uu/tr/locales/en-US.ftl index b2613bf64d3..988cbf8060e 100644 --- a/src/uu/tr/locales/en-US.ftl +++ b/src/uu/tr/locales/en-US.ftl @@ -32,6 +32,8 @@ tr-error-invalid-char-class = invalid character class { $class } tr-error-missing-equivalence-class-char = missing equivalence class character '[==]' tr-error-multiple-char-repeat-in-set2 = only one [c*] repeat construct may appear in string2 tr-error-char-repeat-in-set1 = the [c*] repeat construct may not appear in string1 +tr-error-char-repeat-in-set2-when-not-translating = the [c*] construct may appear in string2 only when translating +tr-error-equivalence-class-in-set2-when-translating = [=c=] expressions may not appear in string2 when translating 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' diff --git a/src/uu/tr/locales/fr-FR.ftl b/src/uu/tr/locales/fr-FR.ftl index dd1f1966de2..2bbbe9e7aaf 100644 --- a/src/uu/tr/locales/fr-FR.ftl +++ b/src/uu/tr/locales/fr-FR.ftl @@ -33,6 +33,8 @@ tr-error-invalid-char-class = classe de caractères non valide { $class } tr-error-missing-equivalence-class-char = caractère de classe d'équivalence manquant '[==]' tr-error-multiple-char-repeat-in-set2 = seule une construction de répétition [c*] peut apparaître dans string2 tr-error-char-repeat-in-set1 = la construction de répétition [c*] ne peut pas apparaître dans string1 +tr-error-char-repeat-in-set2-when-not-translating = la construction [c*] ne peut apparaître dans string2 que lors de la traduction +tr-error-equivalence-class-in-set2-when-translating = les expressions [=c=] ne peuvent pas apparaître dans string2 lors de la traduction 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' diff --git a/src/uu/tr/src/diagnostics.rs b/src/uu/tr/src/diagnostics.rs index f225f0a00d4..15dfa35fab0 100644 --- a/src/uu/tr/src/diagnostics.rs +++ b/src/uu/tr/src/diagnostics.rs @@ -79,6 +79,8 @@ fn describe(error: &BadSequence) -> Option<(Option, Option<&'static str> Some("tr-diag-help-backwards-range"), )), BadSequence::MultipleCharRepeatInSet2 + | BadSequence::CharRepeatInSet2WhenNotTranslating + | BadSequence::EquivalenceClassInSet2WhenTranslating | BadSequence::ClassExceptLowerUpperInSet2 | BadSequence::ClassInSet2NotMatchedBySet1 => Some((None, None)), BadSequence::Set1LongerSet2EndsInClass => Some(( diff --git a/src/uu/tr/src/operation.rs b/src/uu/tr/src/operation.rs index 420dd69f935..46e1d700fce 100644 --- a/src/uu/tr/src/operation.rs +++ b/src/uu/tr/src/operation.rs @@ -41,6 +41,8 @@ pub enum BadSequence { MissingEquivalentClassChar, MultipleCharRepeatInSet2, CharRepeatInSet1, + CharRepeatInSet2WhenNotTranslating, + EquivalenceClassInSet2WhenTranslating, InvalidRepeatCount(String), EmptySet2WhenNotTruncatingSet1, ClassExceptLowerUpperInSet2, @@ -87,6 +89,20 @@ impl Display for BadSequence { Self::CharRepeatInSet1 => { write!(f, "{}", translate!("tr-error-char-repeat-in-set1")) } + Self::CharRepeatInSet2WhenNotTranslating => { + write!( + f, + "{}", + translate!("tr-error-char-repeat-in-set2-when-not-translating") + ) + } + Self::EquivalenceClassInSet2WhenTranslating => { + write!( + f, + "{}", + translate!("tr-error-equivalence-class-in-set2-when-translating") + ) + } Self::InvalidRepeatCount(count) => { write!( f, @@ -202,6 +218,8 @@ pub enum Class { #[derive(Debug, Clone, Copy)] pub enum Sequence { Char(u8), + /// A `[=c=]` equivalence class, which is only the character itself. + Equivalence(u8), CharRange(u8, u8), CharStar(u8), CharRepeat(u8, usize), @@ -211,7 +229,7 @@ pub enum Sequence { impl Sequence { pub fn flatten(&self) -> Box> { match self { - Self::Char(c) => Box::new(std::iter::once(*c)), + Self::Char(c) | Self::Equivalence(c) => Box::new(std::iter::once(*c)), Self::CharRange(l, r) => Box::new(*l..=*r), Self::CharStar(c) => Box::new(std::iter::repeat(*c)), Self::CharRepeat(c, n) => Box::new(std::iter::repeat_n(*c, *n)), @@ -255,7 +273,7 @@ impl Sequence { /// the count of a `[c*n]` repeat can be far too large to materialize. fn expanded_len(&self) -> usize { match self { - Self::Char(_) => 1, + Self::Char(_) | Self::Equivalence(_) => 1, Self::CharRange(l, r) => usize::from(*r) - usize::from(*l) + 1, // A star is only sized once it has been turned into a repeat. Self::CharStar(_) => 0, @@ -349,6 +367,21 @@ impl Sequence { )); } + // `[=c=]` in set2 is only for the set to squeeze with `-d -s`, and `[c*]` + // only fills set2 up to the length of set1 when translating. + if translating && set2.iter().any(|s| matches!(s, Self::Equivalence(_))) { + return Err(SequenceError::whole_set( + BadSequence::EquivalenceClassInSet2WhenTranslating, + 2, + )); + } + if !translating && set2.iter().any(|s| matches!(s, Self::CharStar(_))) { + return Err(SequenceError::whole_set( + BadSequence::CharRepeatInSet2WhenNotTranslating, + 2, + )); + } + if translating && set2.iter().any(|&x| { matches!(x, Self::Class(_)) @@ -759,7 +792,7 @@ impl Sequence { l, match (a, b) { (Err(()), _) => Err(BadSequence::MissingEquivalentClassChar), - (Ok(c), Ok(())) => Ok(Self::Char(c)), + (Ok(c), Ok(())) => Ok(Self::Equivalence(c)), (Ok(c), Err(v)) => Err(BadSequence::MultipleCharInEquivalence(format!( "{}{}", String::from_utf8_lossy(&[c]), diff --git a/tests/by-util/test_tr.rs b/tests/by-util/test_tr.rs index 29ab931722c..d24d845a0d0 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -1271,6 +1271,52 @@ fn test_repeat_in_set1_padded_by_star_in_set2() { .stdout_only("yyz"); } +#[test] +fn test_star_in_set2_only_when_translating() { + // With `-d -s` set2 is only the set to squeeze, which a star cannot fill. + for set2 in ["[b*]", "[b*0]", "b[b*]c", "[=b=][b*]"] { + new_ucmd!() + .args(&["-ds", "a", set2]) + .pipe_in("xaab") + .fails_with_code(1) + .stderr_only("tr: the [c*] construct may appear in string2 only when translating\n"); + } + // A repeat with a count is still allowed. + new_ucmd!() + .args(&["-ds", "a", "[b*3]"]) + .pipe_in("xaabb") + .succeeds() + .stdout_only("xb"); +} + +#[test] +fn test_equivalence_class_in_set2_only_with_delete_and_squeeze() { + for args in [ + &["a", "[=b=]"][..], + &["a", "b[=b=]"], + &["-s", "a", "[=b=]"], + &["-t", "a", "[=b=]"], + &["-c", "a", "[=b=]"], + &["a", "[=b=][:digit:]"], + ] { + new_ucmd!() + .args(args) + .pipe_in("xaab") + .fails_with_code(1) + .stderr_only("tr: [=c=] expressions may not appear in string2 when translating\n"); + } + new_ucmd!() + .args(&["-ds", "a", "[=b=]"]) + .pipe_in("xaabb") + .succeeds() + .stdout_only("xb"); + new_ucmd!() + .args(&["[=a=]", "b"]) + .pipe_in("xaab") + .succeeds() + .stdout_only("xbbb"); +} + #[test] fn test_non_digit_repeat() { new_ucmd!() From 1c28ba8b3fd92bce2bf9690dbb8b10e09ee60ca7 Mon Sep 17 00:00:00 2001 From: Yunus Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:12:38 +0300 Subject: [PATCH 2/5] tr: use rstest cases for the set2 construct tests --- tests/by-util/test_tr.rs | 76 ++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/by-util/test_tr.rs b/tests/by-util/test_tr.rs index d24d845a0d0..554049a38be 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -1271,50 +1271,50 @@ fn test_repeat_in_set1_padded_by_star_in_set2() { .stdout_only("yyz"); } -#[test] -fn test_star_in_set2_only_when_translating() { - // With `-d -s` set2 is only the set to squeeze, which a star cannot fill. - for set2 in ["[b*]", "[b*0]", "b[b*]c", "[=b=][b*]"] { - new_ucmd!() - .args(&["-ds", "a", set2]) - .pipe_in("xaab") - .fails_with_code(1) - .stderr_only("tr: the [c*] construct may appear in string2 only when translating\n"); - } - // A repeat with a count is still allowed. +// With `-d -s` set2 is only the set to squeeze, which a star cannot fill. +#[rstest] +#[case::star("[b*]")] +#[case::star_zero("[b*0]")] +#[case::star_in_middle("b[b*]c")] +#[case::star_after_equivalence("[=b=][b*]")] +fn test_star_in_set2_only_when_translating(#[case] set2: &str) { new_ucmd!() - .args(&["-ds", "a", "[b*3]"]) - .pipe_in("xaabb") - .succeeds() - .stdout_only("xb"); + .args(&["-ds", "a", set2]) + .pipe_in("xaab") + .fails_with_code(1) + .stderr_only("tr: the [c*] construct may appear in string2 only when translating\n"); } -#[test] -fn test_equivalence_class_in_set2_only_with_delete_and_squeeze() { - for args in [ - &["a", "[=b=]"][..], - &["a", "b[=b=]"], - &["-s", "a", "[=b=]"], - &["-t", "a", "[=b=]"], - &["-c", "a", "[=b=]"], - &["a", "[=b=][:digit:]"], - ] { - new_ucmd!() - .args(args) - .pipe_in("xaab") - .fails_with_code(1) - .stderr_only("tr: [=c=] expressions may not appear in string2 when translating\n"); - } - new_ucmd!() - .args(&["-ds", "a", "[=b=]"]) - .pipe_in("xaabb") - .succeeds() - .stdout_only("xb"); +#[rstest] +#[case::translate(&["a", "[=b=]"])] +#[case::translate_after_char(&["a", "b[=b=]"])] +#[case::translate_and_squeeze(&["-s", "a", "[=b=]"])] +#[case::truncate(&["-t", "a", "[=b=]"])] +#[case::complement(&["-c", "a", "[=b=]"])] +#[case::before_class(&["a", "[=b=][:digit:]"])] +fn test_equivalence_class_in_set2_only_with_delete_and_squeeze(#[case] args: &[&str]) { new_ucmd!() - .args(&["[=a=]", "b"]) + .args(args) .pipe_in("xaab") + .fails_with_code(1) + .stderr_only("tr: [=c=] expressions may not appear in string2 when translating\n"); +} + +#[rstest] +// A repeat with a count still fills set2 with `-d -s`. +#[case::delete_squeeze_repeat(&["-ds", "a", "[b*3]"], "xaabb", "xb")] +#[case::delete_squeeze_equivalence(&["-ds", "a", "[=b=]"], "xaabb", "xb")] +#[case::equivalence_in_set1(&["[=a=]", "b"], "xaab", "xbbb")] +fn test_set2_constructs_allowed( + #[case] args: &[&str], + #[case] input: &str, + #[case] expected: &str, +) { + new_ucmd!() + .args(args) + .pipe_in(input) .succeeds() - .stdout_only("xbbb"); + .stdout_only(expected); } #[test] From 54b84d6e013524cf3b84f6b549cfeee32e07a229 Mon Sep 17 00:00:00 2001 From: Yunus Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:55:01 +0300 Subject: [PATCH 3/5] tests/tr: don't pipe stdin into the usage-error cases --- tests/by-util/test_tr.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/by-util/test_tr.rs b/tests/by-util/test_tr.rs index 554049a38be..3098e5e681c 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -1280,7 +1280,6 @@ fn test_repeat_in_set1_padded_by_star_in_set2() { fn test_star_in_set2_only_when_translating(#[case] set2: &str) { new_ucmd!() .args(&["-ds", "a", set2]) - .pipe_in("xaab") .fails_with_code(1) .stderr_only("tr: the [c*] construct may appear in string2 only when translating\n"); } @@ -1295,7 +1294,6 @@ fn test_star_in_set2_only_when_translating(#[case] set2: &str) { fn test_equivalence_class_in_set2_only_with_delete_and_squeeze(#[case] args: &[&str]) { new_ucmd!() .args(args) - .pipe_in("xaab") .fails_with_code(1) .stderr_only("tr: [=c=] expressions may not appear in string2 when translating\n"); } From b7b415480aa35c61b234cdd6d102a12d7bb39569 Mon Sep 17 00:00:00 2001 From: Yunus Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:20:15 +0300 Subject: [PATCH 4/5] tests/tr: split the allowed set2 constructs test --- tests/by-util/test_tr.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/by-util/test_tr.rs b/tests/by-util/test_tr.rs index 3098e5e681c..82ae5b649c1 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -1298,21 +1298,25 @@ fn test_equivalence_class_in_set2_only_with_delete_and_squeeze(#[case] args: &[& .stderr_only("tr: [=c=] expressions may not appear in string2 when translating\n"); } -#[rstest] // A repeat with a count still fills set2 with `-d -s`. -#[case::delete_squeeze_repeat(&["-ds", "a", "[b*3]"], "xaabb", "xb")] -#[case::delete_squeeze_equivalence(&["-ds", "a", "[=b=]"], "xaabb", "xb")] -#[case::equivalence_in_set1(&["[=a=]", "b"], "xaab", "xbbb")] -fn test_set2_constructs_allowed( - #[case] args: &[&str], - #[case] input: &str, - #[case] expected: &str, -) { +#[rstest] +#[case::repeat("[b*3]")] +#[case::equivalence("[=b=]")] +fn test_set2_constructs_allowed_with_delete_and_squeeze(#[case] set2: &str) { new_ucmd!() - .args(args) - .pipe_in(input) + .args(&["-ds", "a", set2]) + .pipe_in("xaabb") .succeeds() - .stdout_only(expected); + .stdout_only("xb"); +} + +#[test] +fn test_equivalence_class_in_set1_when_translating() { + new_ucmd!() + .args(&["[=a=]", "b"]) + .pipe_in("xaab") + .succeeds() + .stdout_only("xbbb"); } #[test] From 9a983b14738ec865088bf39cff8d9190ba9b435f Mon Sep 17 00:00:00 2001 From: Yunus Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:48:42 +0300 Subject: [PATCH 5/5] tr: reuse is_char_star for the set2 check --- src/uu/tr/src/operation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/tr/src/operation.rs b/src/uu/tr/src/operation.rs index 46e1d700fce..563796c45ed 100644 --- a/src/uu/tr/src/operation.rs +++ b/src/uu/tr/src/operation.rs @@ -375,7 +375,7 @@ impl Sequence { 2, )); } - if !translating && set2.iter().any(|s| matches!(s, Self::CharStar(_))) { + if !translating && set2.iter().any(|s| is_char_star(&s)) { return Err(SequenceError::whole_set( BadSequence::CharRepeatInSet2WhenNotTranslating, 2,