Skip to content

Commit 3dcee17

Browse files
authored
Merge pull request #7675 from Qelxiros/7669-sleep-hex-parsing
fix(sleep): use uucore's from_str to support parsing hex
2 parents a067b58 + 7f2fb04 commit 3dcee17

File tree

6 files changed

+122
-88
lines changed

6 files changed

+122
-88
lines changed

src/uu/sleep/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ path = "src/sleep.rs"
2020
[dependencies]
2121
clap = { workspace = true }
2222
fundu = { workspace = true }
23-
uucore = { workspace = true }
23+
uucore = { workspace = true, features = ["parser"] }
2424

2525
[[bin]]
2626
name = "sleep"

src/uu/sleep/src/sleep.rs

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ use std::time::Duration;
88

99
use uucore::{
1010
error::{UResult, USimpleError, UUsageError},
11-
format_usage, help_about, help_section, help_usage, show_error,
11+
format_usage, help_about, help_section, help_usage,
12+
parser::parse_time,
13+
show_error,
1214
};
1315

1416
use clap::{Arg, ArgAction, Command};
15-
use fundu::{DurationParser, ParseError, SaturatingInto};
1617

1718
static ABOUT: &str = help_about!("sleep.md");
1819
const USAGE: &str = help_usage!("sleep.md");
@@ -61,37 +62,17 @@ pub fn uu_app() -> Command {
6162
fn sleep(args: &[&str]) -> UResult<()> {
6263
let mut arg_error = false;
6364

64-
use fundu::TimeUnit::{Day, Hour, Minute, Second};
65-
let parser = DurationParser::with_time_units(&[Second, Minute, Hour, Day]);
66-
6765
let sleep_dur = args
6866
.iter()
69-
.filter_map(|input| match parser.parse(input.trim()) {
67+
.filter_map(|input| match parse_time::from_str(input) {
7068
Ok(duration) => Some(duration),
7169
Err(error) => {
7270
arg_error = true;
73-
74-
let reason = match error {
75-
ParseError::Empty if input.is_empty() => "Input was empty".to_string(),
76-
ParseError::Empty => "Found only whitespace in input".to_string(),
77-
ParseError::Syntax(pos, description)
78-
| ParseError::TimeUnit(pos, description) => {
79-
format!("{description} at position {}", pos.saturating_add(1))
80-
}
81-
ParseError::NegativeExponentOverflow | ParseError::PositiveExponentOverflow => {
82-
"Exponent was out of bounds".to_string()
83-
}
84-
ParseError::NegativeNumber => "Number was negative".to_string(),
85-
error => error.to_string(),
86-
};
87-
show_error!("invalid time interval '{input}': {reason}");
88-
71+
show_error!("{error}");
8972
None
9073
}
9174
})
92-
.fold(Duration::ZERO, |acc, n| {
93-
acc.saturating_add(SaturatingInto::<Duration>::saturating_into(n))
94-
});
75+
.fold(Duration::ZERO, |acc, n| acc.saturating_add(n));
9576

9677
if arg_error {
9778
return Err(UUsageError::new(1, ""));

src/uucore/src/lib/features/parser/num_parser.rs

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ impl ExtendedParser for i64 {
150150
}
151151
}
152152

153-
match parse(input, true) {
153+
match parse(input, ParseTarget::Integral, &[]) {
154154
Ok(v) => into_i64(v),
155155
Err(e) => Err(e.map(into_i64)),
156156
}
@@ -187,7 +187,7 @@ impl ExtendedParser for u64 {
187187
}
188188
}
189189

190-
match parse(input, true) {
190+
match parse(input, ParseTarget::Integral, &[]) {
191191
Ok(v) => into_u64(v),
192192
Err(e) => Err(e.map(into_u64)),
193193
}
@@ -219,7 +219,7 @@ impl ExtendedParser for f64 {
219219
Ok(v)
220220
}
221221

222-
match parse(input, false) {
222+
match parse(input, ParseTarget::Decimal, &[]) {
223223
Ok(v) => into_f64(v),
224224
Err(e) => Err(e.map(into_f64)),
225225
}
@@ -231,14 +231,15 @@ impl ExtendedParser for ExtendedBigDecimal {
231231
fn extended_parse(
232232
input: &str,
233233
) -> Result<ExtendedBigDecimal, ExtendedParserError<'_, ExtendedBigDecimal>> {
234-
parse(input, false)
234+
parse(input, ParseTarget::Decimal, &[])
235235
}
236236
}
237237

238-
fn parse_special_value(
239-
input: &str,
238+
fn parse_special_value<'a>(
239+
input: &'a str,
240240
negative: bool,
241-
) -> Result<ExtendedBigDecimal, ExtendedParserError<'_, ExtendedBigDecimal>> {
241+
allowed_suffixes: &'a [(char, u32)],
242+
) -> Result<ExtendedBigDecimal, ExtendedParserError<'a, ExtendedBigDecimal>> {
242243
let input_lc = input.to_ascii_lowercase();
243244

244245
// Array of ("String to match", return value when sign positive, when sign negative)
@@ -254,7 +255,14 @@ fn parse_special_value(
254255
if negative {
255256
special = -special;
256257
}
257-
let match_len = str.len();
258+
let mut match_len = str.len();
259+
if let Some(ch) = input.chars().nth(str.chars().count()) {
260+
if allowed_suffixes.iter().any(|(c, _)| ch == *c) {
261+
// multiplying is unnecessary for these special values, but we have to note that
262+
// we processed the character to avoid a partial match error
263+
match_len += 1;
264+
}
265+
}
258266
return if input.len() == match_len {
259267
Ok(special)
260268
} else {
@@ -381,24 +389,34 @@ fn construct_extended_big_decimal<'a>(
381389
Ok(ExtendedBigDecimal::BigDecimal(bd))
382390
}
383391

392+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393+
pub(crate) enum ParseTarget {
394+
Decimal,
395+
Integral,
396+
Duration,
397+
}
398+
384399
// TODO: As highlighted by clippy, this function _is_ high cognitive complexity, jumps
385400
// around between integer and float parsing, and should be split in multiple parts.
386401
#[allow(clippy::cognitive_complexity)]
387-
fn parse(
388-
input: &str,
389-
integral_only: bool,
390-
) -> Result<ExtendedBigDecimal, ExtendedParserError<'_, ExtendedBigDecimal>> {
402+
pub(crate) fn parse<'a>(
403+
input: &'a str,
404+
target: ParseTarget,
405+
allowed_suffixes: &'a [(char, u32)],
406+
) -> Result<ExtendedBigDecimal, ExtendedParserError<'a, ExtendedBigDecimal>> {
391407
// Parse the " and ' prefixes separately
392-
if let Some(rest) = input.strip_prefix(['\'', '"']) {
393-
let mut chars = rest.char_indices().fuse();
394-
let v = chars
395-
.next()
396-
.map(|(_, c)| ExtendedBigDecimal::BigDecimal(u32::from(c).into()));
397-
return match (v, chars.next()) {
398-
(Some(v), None) => Ok(v),
399-
(Some(v), Some((i, _))) => Err(ExtendedParserError::PartialMatch(v, &rest[i..])),
400-
(None, _) => Err(ExtendedParserError::NotNumeric),
401-
};
408+
if target != ParseTarget::Duration {
409+
if let Some(rest) = input.strip_prefix(['\'', '"']) {
410+
let mut chars = rest.char_indices().fuse();
411+
let v = chars
412+
.next()
413+
.map(|(_, c)| ExtendedBigDecimal::BigDecimal(u32::from(c).into()));
414+
return match (v, chars.next()) {
415+
(Some(v), None) => Ok(v),
416+
(Some(v), Some((i, _))) => Err(ExtendedParserError::PartialMatch(v, &rest[i..])),
417+
(None, _) => Err(ExtendedParserError::NotNumeric),
418+
};
419+
}
402420
}
403421

404422
let trimmed_input = input.trim_ascii_start();
@@ -419,7 +437,7 @@ fn parse(
419437
let (base, rest) = if let Some(rest) = unsigned.strip_prefix('0') {
420438
if let Some(rest) = rest.strip_prefix(['x', 'X']) {
421439
(Base::Hexadecimal, rest)
422-
} else if integral_only {
440+
} else if target == ParseTarget::Integral {
423441
// Binary/Octal only allowed for integer parsing.
424442
if let Some(rest) = rest.strip_prefix(['b', 'B']) {
425443
(Base::Binary, rest)
@@ -447,7 +465,7 @@ fn parse(
447465
}
448466

449467
// Parse fractional/exponent part of the number for supported bases.
450-
if matches!(base, Base::Decimal | Base::Hexadecimal) && !integral_only {
468+
if matches!(base, Base::Decimal | Base::Hexadecimal) && target != ParseTarget::Integral {
451469
// Parse the fractional part of the number if there can be one and the input contains
452470
// a '.' decimal separator.
453471
if matches!(chars.peek(), Some(&(_, '.'))) {
@@ -493,13 +511,24 @@ fn parse(
493511

494512
// If nothing has been parsed, check if this is a special value, or declare the parsing unsuccessful
495513
if let Some((0, _)) = chars.peek() {
496-
return if integral_only {
514+
return if target == ParseTarget::Integral {
497515
Err(ExtendedParserError::NotNumeric)
498516
} else {
499-
parse_special_value(unsigned, negative)
517+
parse_special_value(unsigned, negative, allowed_suffixes)
500518
};
501519
}
502520

521+
if let Some((_, ch)) = chars.peek() {
522+
if let Some(times) = allowed_suffixes
523+
.iter()
524+
.find(|(c, _)| ch == c)
525+
.map(|&(_, t)| t)
526+
{
527+
chars.next();
528+
digits *= times;
529+
}
530+
}
531+
503532
let ebd_result = construct_extended_big_decimal(digits, negative, base, scale, exponent);
504533

505534
// Return what has been parsed so far. If there are extra characters, mark the

src/uucore/src/lib/features/parser/parse_time.rs

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@
1111
use crate::{
1212
display::Quotable,
1313
extendedbigdecimal::ExtendedBigDecimal,
14-
parser::num_parser::{ExtendedParser, ExtendedParserError},
14+
parser::num_parser::{self, ExtendedParserError, ParseTarget},
1515
};
16-
use bigdecimal::BigDecimal;
1716
use num_traits::Signed;
1817
use num_traits::ToPrimitive;
1918
use num_traits::Zero;
@@ -59,26 +58,18 @@ pub fn from_str(string: &str) -> Result<Duration, String> {
5958

6059
let len = string.len();
6160
if len == 0 {
62-
return Err("empty string".to_owned());
63-
}
64-
let Some(slice) = string.get(..len - 1) else {
6561
return Err(format!("invalid time interval {}", string.quote()));
66-
};
67-
let (numstr, times) = match string.chars().next_back().unwrap() {
68-
's' => (slice, 1),
69-
'm' => (slice, 60),
70-
'h' => (slice, 60 * 60),
71-
'd' => (slice, 60 * 60 * 24),
72-
val if !val.is_alphabetic() => (string, 1),
73-
_ => match string.to_ascii_lowercase().as_str() {
74-
"inf" | "infinity" => ("inf", 1),
75-
_ => return Err(format!("invalid time interval {}", string.quote())),
76-
},
77-
};
78-
let num = match ExtendedBigDecimal::extended_parse(numstr) {
62+
}
63+
let num = match num_parser::parse(
64+
string,
65+
ParseTarget::Duration,
66+
&[('s', 1), ('m', 60), ('h', 60 * 60), ('d', 60 * 60 * 24)],
67+
) {
7968
Ok(ebd) | Err(ExtendedParserError::Overflow(ebd)) => ebd,
8069
Err(ExtendedParserError::Underflow(_)) => return Ok(NANOSECOND_DURATION),
81-
_ => return Err(format!("invalid time interval {}", string.quote())),
70+
_ => {
71+
return Err(format!("invalid time interval {}", string.quote()));
72+
}
8273
};
8374

8475
// Allow non-negative durations (-0 is fine), and infinity.
@@ -89,9 +80,6 @@ pub fn from_str(string: &str) -> Result<Duration, String> {
8980
_ => return Err(format!("invalid time interval {}", string.quote())),
9081
};
9182

92-
// Pre-multiply times to avoid precision loss
93-
let num: BigDecimal = num * times;
94-
9583
// Transform to nanoseconds (9 digits after decimal point)
9684
let (nanos_bi, _) = num.with_scale(9).into_bigint_and_scale();
9785

tests/by-util/test_sleep.rs

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
// file that was distributed with this source code.
55
use rstest::rstest;
66

7-
// spell-checker:ignore dont SIGBUS SIGSEGV sigsegv sigbus
7+
use uucore::display::Quotable;
8+
// spell-checker:ignore dont SIGBUS SIGSEGV sigsegv sigbus infd
89
use uutests::new_ucmd;
910
use uutests::util::TestScenario;
1011
use uutests::util_name;
@@ -19,11 +20,11 @@ fn test_invalid_time_interval() {
1920
new_ucmd!()
2021
.arg("xyz")
2122
.fails()
22-
.usage_error("invalid time interval 'xyz': Invalid input: xyz");
23+
.usage_error("invalid time interval 'xyz'");
2324
new_ucmd!()
2425
.args(&["--", "-1"])
2526
.fails()
26-
.usage_error("invalid time interval '-1': Number was negative");
27+
.usage_error("invalid time interval '-1'");
2728
}
2829

2930
#[test]
@@ -228,16 +229,25 @@ fn test_sleep_when_multiple_inputs_exceed_max_duration_then_no_error() {
228229
#[rstest]
229230
#[case::whitespace_prefix(" 0.1s")]
230231
#[case::multiple_whitespace_prefix(" 0.1s")]
231-
#[case::whitespace_suffix("0.1s ")]
232-
#[case::mixed_newlines_spaces_tabs("\n\t0.1s \n ")]
233-
fn test_sleep_when_input_has_whitespace_then_no_error(#[case] input: &str) {
232+
fn test_sleep_when_input_has_leading_whitespace_then_no_error(#[case] input: &str) {
234233
new_ucmd!()
235234
.arg(input)
236235
.timeout(Duration::from_secs(10))
237236
.succeeds()
238237
.no_output();
239238
}
240239

240+
#[rstest]
241+
#[case::whitespace_suffix("0.1s ")]
242+
#[case::mixed_newlines_spaces_tabs("\n\t0.1s \n ")]
243+
fn test_sleep_when_input_has_trailing_whitespace_then_error(#[case] input: &str) {
244+
new_ucmd!()
245+
.arg(input)
246+
.timeout(Duration::from_secs(10))
247+
.fails()
248+
.usage_error(format!("invalid time interval {}", input.quote()));
249+
}
250+
241251
#[rstest]
242252
#[case::only_space(" ")]
243253
#[case::only_tab("\t")]
@@ -247,16 +257,14 @@ fn test_sleep_when_input_has_only_whitespace_then_error(#[case] input: &str) {
247257
.arg(input)
248258
.timeout(Duration::from_secs(10))
249259
.fails()
250-
.usage_error(format!(
251-
"invalid time interval '{input}': Found only whitespace in input"
252-
));
260+
.usage_error(format!("invalid time interval {}", input.quote()));
253261
}
254262

255263
#[test]
256264
fn test_sleep_when_multiple_input_some_with_error_then_shows_all_errors() {
257-
let expected = "invalid time interval 'abc': Invalid input: abc\n\
258-
sleep: invalid time interval '1years': Invalid time unit: 'years' at position 2\n\
259-
sleep: invalid time interval ' ': Found only whitespace in input";
265+
let expected = "invalid time interval 'abc'\n\
266+
sleep: invalid time interval '1years'\n\
267+
sleep: invalid time interval ' '";
260268

261269
// Even if one of the arguments is valid, but the rest isn't, we should still fail and exit early.
262270
// So, the timeout of 10 seconds ensures we haven't executed `thread::sleep` with the only valid
@@ -273,7 +281,35 @@ fn test_negative_interval() {
273281
new_ucmd!()
274282
.args(&["--", "-1"])
275283
.fails()
276-
.usage_error("invalid time interval '-1': Number was negative");
284+
.usage_error("invalid time interval '-1'");
285+
}
286+
287+
#[rstest]
288+
#[case::int("0x0")]
289+
#[case::negative_zero("-0x0")]
290+
#[case::int_suffix("0x0s")]
291+
#[case::int_suffix("0x0h")]
292+
#[case::frac("0x0.1")]
293+
#[case::frac_suffix("0x0.1s")]
294+
#[case::frac_suffix("0x0.001h")]
295+
#[case::scientific("0x1.0p-3")]
296+
#[case::scientific_suffix("0x1.0p-4s")]
297+
fn test_valid_hex_duration(#[case] input: &str) {
298+
new_ucmd!().args(&["--", input]).succeeds().no_output();
299+
}
300+
301+
#[rstest]
302+
#[case::negative("-0x1")]
303+
#[case::negative_suffix("-0x1s")]
304+
#[case::negative_frac_suffix("-0x0.1s")]
305+
#[case::wrong_capitalization("infD")]
306+
#[case::wrong_capitalization("INFD")]
307+
#[case::wrong_capitalization("iNfD")]
308+
fn test_invalid_hex_duration(#[case] input: &str) {
309+
new_ucmd!()
310+
.args(&["--", input])
311+
.fails()
312+
.usage_error(format!("invalid time interval {}", input.quote()));
277313
}
278314

279315
#[cfg(unix)]

tests/by-util/test_timeout.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ fn test_command_empty_args() {
7777
new_ucmd!()
7878
.args(&["", ""])
7979
.fails()
80-
.stderr_contains("timeout: empty string");
80+
.stderr_contains("timeout: invalid time interval ''");
8181
}
8282

8383
#[test]

0 commit comments

Comments
 (0)