From a1976903ab4d722f8814553461eaa481d77ffcf1 Mon Sep 17 00:00:00 2001 From: "Tom D." <15268361+anastygnome@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:49:37 +0200 Subject: [PATCH] date: expand GNU-compatible parsing and formatting --- src/uu/date/locales/en-US.ftl | 3 +- src/uu/date/locales/fr-FR.ftl | 3 +- src/uu/date/src/date.rs | 1104 +++++++++++++++++++++++---- src/uu/date/src/format_modifiers.rs | 220 ++++-- tests/by-util/test_date.rs | 239 +++++- 5 files changed, 1326 insertions(+), 243 deletions(-) diff --git a/src/uu/date/locales/en-US.ftl b/src/uu/date/locales/en-US.ftl index 33e16eef679..f39a2549f4b 100644 --- a/src/uu/date/locales/en-US.ftl +++ b/src/uu/date/locales/en-US.ftl @@ -106,6 +106,7 @@ date-error-cannot-set-date = cannot set date date-error-extra-operand = extra operand '{$operand}' date-error-write = write error: {$error} date-error-format-modifier-width-too-large = format modifier width '{$width}' is too large for specifier '%{$specifier}' -date-error-format-missing-plus = the argument {$arg} lacks a leading '+'; +date-error-format-missing-plus = the argument '{$arg}' lacks a leading '+'; when using an option to specify date(s), any non-option argument must be a format string beginning with '+' +date-error-multiple-output-formats = multiple output formats specified diff --git a/src/uu/date/locales/fr-FR.ftl b/src/uu/date/locales/fr-FR.ftl index 2398691565c..2187e79149b 100644 --- a/src/uu/date/locales/fr-FR.ftl +++ b/src/uu/date/locales/fr-FR.ftl @@ -101,6 +101,7 @@ date-error-cannot-set-date = impossible de définir la date date-error-extra-operand = opérande supplémentaire '{$operand}' date-error-write = erreur d'écriture: {$error} date-error-format-modifier-width-too-large = la largeur du modificateur de format '{$width}' est trop grande pour le spécificateur '%{$specifier}' -date-error-format-missing-plus = l'argument {$arg} ne commence pas par un signe '+'; +date-error-format-missing-plus = l'argument '{$arg}' ne commence pas par un signe '+'; lorsqu'une option est utilisée pour spécifier une ou plusieurs dates, tout argument autre qu'une option doit être une chaîne de format commençant par un signe '+'. +date-error-multiple-output-formats = plusieurs formats de sortie spécifiés diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 09c29301b97..6e75c4f610e 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -4,19 +4,20 @@ // file that was distributed with this source code. // spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz unparseable -// spell-checker:ignore ohos OHOS tzdata tzdb tzif zoneinfo euctw +// spell-checker:ignore ohos OHOS tzdata tzdb tzif zoneinfo euctw nonrelative wednes mod format_modifiers; mod locale; use clap::{Arg, ArgAction, Command}; use jiff::fmt::strtime::{self, BrokenDownTime, Config, PosixCustom}; -use jiff::tz::{Offset, TimeZone, TimeZoneDatabase}; +use jiff::tz::{AmbiguousOffset, Offset, TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; use parse_datetime::{ExtendedDateTime, ParsedDateTime}; use std::borrow::Cow; use std::collections::HashMap; use std::ffi::{OsStr, OsString}; +use std::fmt::Write as _; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Read, Write, stderr}; use std::path::PathBuf; @@ -26,6 +27,7 @@ use uucore::error::FromIo; use uucore::error::{UError, UResult, strip_errno}; #[cfg(feature = "i18n-datetime")] use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale}; +use uucore::i18n::{UEncoding, get_locale_from_env}; use uucore::translate; use uucore::translate_text; use uucore::{format_usage, show, show_if_err}; @@ -96,6 +98,8 @@ enum DateError { InvalidDate { date: String }, #[error("{}", translate_text!("date-error-format-missing-plus", "arg" => .arg))] FormatMissingPlus { arg: String }, + #[error("{}", translate!("date-error-multiple-output-formats"))] + MultipleOutputFormats, #[error("{}", translate!("date-error-expected-file-got-directory", "path" => .path))] ExpectedFileGotDirectory { path: String }, #[error("{}", translate!("date-error-cannot-set-date", "path" => .path, "error" => .error))] @@ -231,28 +235,58 @@ enum DayDelta { /// assert_eq!(escape_invalid_bytes(invalid), "\\260"); /// ``` fn escape_invalid_bytes(bytes: &[u8]) -> String { - let escaped = bytes - .iter() - .flat_map(|&b| { - // Preserve printable ASCII except backslash - if (0x20..0x7f).contains(&b) && b != b'\\' { - vec![b] + let mut output = String::new(); + let mut rest = bytes; + let preserve_utf8 = get_locale_from_env("LC_CTYPE").1 == UEncoding::Utf8; + + // Decode valid runs so UTF-8 locales preserve user-facing text while + // legacy locales retain GNU's byte-oriented octal escaping. + let append_valid = |output: &mut String, valid: &str| { + for ch in valid.chars() { + if !ch.is_ascii() && !preserve_utf8 { + let mut bytes = [0; 4]; + for &byte in ch.encode_utf8(&mut bytes).as_bytes() { + let _ = write!(output, "\\{byte:03o}"); + } + } else if ch.is_ascii() && (!(0x20..0x7f).contains(&(ch as u8)) || ch == '\\') { + let _ = write!(output, "\\{:03o}", ch as u8); } else { - // Escape as octal: \NNN - format!("\\{b:03o}").into_bytes() + output.push(ch); } - }) - .collect::>(); - String::from_utf8_lossy(&escaped).into_owned() + } + }; + + while !rest.is_empty() { + match std::str::from_utf8(rest) { + Ok(valid) => { + append_valid(&mut output, valid); + break; + } + Err(error) => { + let valid_len = error.valid_up_to(); + if valid_len > 0 { + append_valid( + &mut output, + std::str::from_utf8(&rest[..valid_len]).unwrap_or_default(), + ); + } + let invalid_len = error.error_len().unwrap_or(rest.len() - valid_len); + for &byte in &rest[valid_len..valid_len + invalid_len] { + let _ = write!(output, "\\{byte:03o}"); + } + rest = &rest[valid_len + invalid_len..]; + } + } + } + + output } /// Renders a command-line operand for an error message the way GNU does: -/// valid UTF-8 is kept as-is, anything else is octal-escaped. +/// valid UTF-8 is kept as-is in UTF-8 locales; non-UTF-8 locale bytes and +/// invalid UTF-8 are octal-escaped. fn operand_for_error(operand: &OsStr) -> String { - operand.to_str().map_or_else( - || escape_invalid_bytes(operand.as_encoded_bytes()), - ToOwned::to_owned, - ) + escape_invalid_bytes(operand.as_encoded_bytes()) } /// Strip parenthesized comments from a date string. @@ -304,10 +338,6 @@ fn strip_parenthesized_comments(input: &str) -> Cow<'_, str> { /// The hour offset from digits is added to the base military timezone offset. /// Examples: "m" -> 12 (noon UTC), "m9" -> 21 (9pm UTC), "a5" -> 4 (4am UTC next day) fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { - if s.is_empty() || s.len() > 3 { - return None; - } - let mut chars = s.chars(); let letter = chars.next()?.to_ascii_lowercase(); @@ -315,6 +345,9 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { if !letter.is_ascii_lowercase() || letter == 'j' { return None; } + if s.len() > 3 { + return None; + } // Parse optional digits (1-2 digits for hour offset) let additional_hours: i32 = if let Some(rest) = chars.as_str().chars().next() { @@ -353,7 +386,10 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let date_source = if let Some(date_os) = matches.get_one::(OPT_DATE) { + let date_source = if let Some(date_os) = matches + .get_many::(OPT_DATE) + .and_then(|mut dates| dates.next_back()) + { // Convert OsString to String, handling invalid UTF-8 with GNU-compatible error let date = date_os.to_str().ok_or_else(|| DateError::InvalidDate { date: operand_for_error(date_os), @@ -399,7 +435,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Format::Custom(rest.to_vec()) } else if let Some(fmt) = matches .get_many::(OPT_ISO_8601) - .map(|mut iter| iter.next().unwrap_or(&DATE.to_string()).as_str().into()) + .and_then(|mut iter| iter.next_back()) + .map(|fmt| fmt.as_str().into()) { Format::Iso8601(fmt) } else if matches.get_flag(OPT_RFC_EMAIL) { @@ -415,6 +452,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Format::Default }; + let has_custom_format = matches.get_one::(OPT_FORMAT).is_some(); + let has_option_format = matches.get_many::(OPT_ISO_8601).is_some() + || matches.get_flag(OPT_RFC_EMAIL) + || matches.get_one::(OPT_RFC_3339).is_some(); + if has_custom_format && has_option_format { + return Err(Box::new(DateError::MultipleOutputFormats)); + } + let utc = matches.get_flag(OPT_UNIVERSAL); let debug_mode = matches.get_flag(OPT_DEBUG); @@ -443,7 +488,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { return set_and_echo(input, &now, &settings); } - let allow_extended = matches!(settings.format, Format::Default); let output_time_zone = now.time_zone().clone(); // Iterate over all dates - whether it's a single date or a file. @@ -451,31 +495,31 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { DateSource::Human(ref input) => { // GNU compatibility (Comments in parentheses) let input = strip_parenthesized_comments(input); - let input = input.trim(); + let input = input.as_ref(); + let trimmed = input.trim(); let parse = |input: &str, warn_midnight| { parse_date( input, &now, DebugOptions::new(settings.debug, warn_midnight), - allow_extended, ) }; // GNU compatibility (Empty string): // An empty string (or whitespace-only) should be treated as midnight today. - let is_empty_or_whitespace = input.is_empty(); + let is_empty_or_whitespace = trimmed.is_empty(); // GNU compatibility (Military timezone 'J'): // 'J' is reserved for local time in military timezones. // GNU date accepts it and treats it as midnight today (00:00:00). - let is_military_j = input.eq_ignore_ascii_case("j"); + let is_military_j = trimmed.eq_ignore_ascii_case("j"); // GNU compatibility (Military timezone with optional hour offset): // Single letter (a-z except j) optionally followed by 1-2 digits. // Letter represents midnight in that military timezone (UTC offset). // Digits represent additional hours to add. // Examples: "m" -> noon UTC (12:00); "m9" -> 21:00 UTC; "a5" -> 04:00 UTC - let military_tz_with_offset = parse_military_timezone_with_offset(input); + let military_tz_with_offset = parse_military_timezone_with_offset(trimmed); // GNU compatibility (Pure numbers in date strings): // - Manual: https://www.gnu.org/software/coreutils/manual/html_node/Pure-numbers-in-date-strings.html @@ -486,85 +530,86 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // GNU compatibility (Military timezone 'J' after a time): // 'J' is local time, so "j"/"J" is the same time-of-day form // as the bare "" input ("9j" == "9"). Strip it before the digit check. - let time_digits = input.strip_suffix(['j', 'J']).unwrap_or(input); + let time_digits = trimmed.strip_suffix(['j', 'J']).unwrap_or(trimmed); let is_pure_digits = !time_digits.is_empty() && time_digits.len() <= 4 && time_digits.chars().all(|c| c.is_ascii_digit()); - let date = if is_empty_or_whitespace || input == "-" || is_military_j { - // Treat empty string, single hyphen, or 'J' as midnight today in local time - let date_part = - strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); - let offset = if settings.utc { - String::from("+00:00") - } else { - strtime::format("%:z", &now).unwrap_or_default() - }; - let composed = if offset.is_empty() { - format!("{date_part} 00:00") - } else { - format!("{date_part} 00:00 {offset}") - }; - if settings.debug { - let _ = writeln!( - stderr(), - "date: warning: using midnight as starting time: 00:00:00" - ); - } - parse(&composed, false) - } else if let Some((total_hours, day_delta)) = military_tz_with_offset { - // Military timezone with optional hour offset - // Convert to UTC time: midnight + military_tz_offset + additional_hours - - // When calculating a military timezone with an optional hour offset, midnight may - // be crossed in either direction. `day_delta` indicates whether the date remains - // the same, moves to the previous day, or advances to the next day. - // Changing day can result in error, this closure will help handle these errors - // gracefully. - let format_date_with_epoch_fallback = |date: Result| -> String { - date.and_then(|d| strtime::format("%F", &d)) - .unwrap_or_else(|_| String::from("1970-01-01")) - }; - let date_part = match day_delta { - DayDelta::Same => format_date_with_epoch_fallback(Ok(now.clone())), - DayDelta::Next => format_date_with_epoch_fallback(now.tomorrow()), - DayDelta::Previous => format_date_with_epoch_fallback(now.yesterday()), - }; - let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); - parse(&composed, false) - } else if is_pure_digits { - // Derive HH and MM from the digits - let (hh_opt, mm_opt) = if time_digits.len() <= 2 { - (time_digits.parse::().ok(), Some(0u32)) - } else { - let (h, m) = time_digits.split_at(time_digits.len() - 2); - (h.parse::().ok(), m.parse::().ok()) - }; - - if let (Some(hh), Some(mm)) = (hh_opt, mm_opt) { - // Compose a concrete datetime string for today with zone offset. - // Use the already-determined 'now' and settings.utc to select offset. + let date = + if is_empty_or_whitespace || trimmed == "-" || trimmed == "+" || is_military_j { + // Treat empty string, single hyphen, or 'J' as midnight today in local time let date_part = strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); - // If -u, force +00:00; otherwise use the local offset of 'now'. let offset = if settings.utc { String::from("+00:00") } else { strtime::format("%:z", &now).unwrap_or_default() }; let composed = if offset.is_empty() { - format!("{date_part} {hh:02}:{mm:02}") + format!("{date_part} 00:00") } else { - format!("{date_part} {hh:02}:{mm:02} {offset}") + format!("{date_part} 00:00 {offset}") + }; + if settings.debug { + let _ = writeln!( + stderr(), + "date: warning: using midnight as starting time: 00:00:00" + ); + } + parse(&composed, false) + } else if let Some((total_hours, day_delta)) = military_tz_with_offset { + // Military timezone with optional hour offset + // Convert to UTC time: midnight + military_tz_offset + additional_hours + + // When calculating a military timezone with an optional hour offset, midnight may + // be crossed in either direction. `day_delta` indicates whether the date remains + // the same, moves to the previous day, or advances to the next day. + // Changing day can result in error, this closure will help handle these errors + // gracefully. + let format_date_with_epoch_fallback = |date: Result| -> String { + date.and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")) }; + let date_part = match day_delta { + DayDelta::Same => format_date_with_epoch_fallback(Ok(now.clone())), + DayDelta::Next => format_date_with_epoch_fallback(now.tomorrow()), + DayDelta::Previous => format_date_with_epoch_fallback(now.yesterday()), + }; + let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); parse(&composed, false) + } else if is_pure_digits { + // Derive HH and MM from the digits + let (hh_opt, mm_opt) = if time_digits.len() <= 2 { + (time_digits.parse::().ok(), Some(0u32)) + } else { + let (h, m) = time_digits.split_at(time_digits.len() - 2); + (h.parse::().ok(), m.parse::().ok()) + }; + + if let (Some(hh), Some(mm)) = (hh_opt, mm_opt) { + // Compose a concrete datetime string for today with zone offset. + // Use the already-determined 'now' and settings.utc to select offset. + let date_part = strtime::format("%F", &now) + .unwrap_or_else(|_| String::from("1970-01-01")); + // If -u, force +00:00; otherwise use the local offset of 'now'. + let offset = if settings.utc { + String::from("+00:00") + } else { + strtime::format("%:z", &now).unwrap_or_default() + }; + let composed = if offset.is_empty() { + format!("{date_part} {hh:02}:{mm:02}") + } else { + format!("{date_part} {hh:02}:{mm:02} {offset}") + }; + parse(&composed, false) + } else { + // Fallback on parse failure of digits + parse(input, true) + } } else { - // Fallback on parse failure of digits parse(input, true) - } - } else { - parse(input, true) - }; + }; let iter = std::iter::once(date); Box::new(iter) @@ -573,7 +618,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { std::io::stdin(), &now, DebugOptions::new(settings.debug, true), - allow_extended, ), DateSource::File(ref path) => { if path.is_dir() { @@ -583,12 +627,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } let file = File::open(path).map_err_context(|| path.as_os_str().maybe_quote().to_string())?; - parse_dates_from_reader( - file, - &now, - DebugOptions::new(settings.debug, true), - allow_extended, - ) + parse_dates_from_reader(file, &now, DebugOptions::new(settings.debug, true)) } DateSource::FileMtime(ref path) => { let metadata = std::fs::metadata(path) @@ -635,6 +674,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { &output.chunks, &output.config, &output_time_zone, + matches!(&settings.format, Format::Custom(_)), ); write_formatted_bytes(&mut stdout, formatted, &output)?; } @@ -665,7 +705,7 @@ pub fn uu_app() -> Command { .long(OPT_DATE) .value_name("STRING") .allow_hyphen_values(true) - .overrides_with(OPT_DATE) + .action(ArgAction::Append) .value_parser(clap::value_parser!(OsString)) .help(translate!("date-help-date")), ) @@ -690,6 +730,7 @@ pub fn uu_app() -> Command { ])) .num_args(0..=1) .default_missing_value(OPT_DATE) + .action(ArgAction::Append) .help(translate!("date-help-iso-8601")), ) .arg( @@ -775,35 +816,130 @@ pub fn uu_app() -> Command { /// timestamps, whereas jiff's `%s` truncates toward zero. `%%` escapes are /// preserved and every other specifier is left untouched for jiff to render. fn substitute_epoch_seconds(fmt: &str, date: &Zoned) -> String { - if !fmt.contains("%s") { + if !fmt.contains('%') || !fmt.contains('s') { return fmt.to_string(); } - let seconds = ParsedDateTime::InRange(date.clone()) - .unix_epoch_second() - .to_string(); + let seconds = timestamp_seconds_floor(date.timestamp()); + substitute_epoch_seconds_value(fmt, seconds) +} - let mut out = String::with_capacity(fmt.len()); - let mut chars = fmt.chars().peekable(); - while let Some(c) = chars.next() { - if c != '%' { - out.push(c); +fn timestamp_seconds_floor(timestamp: Timestamp) -> i64 { + timestamp.as_second() - i64::from(timestamp.subsec_nanosecond() < 0) +} + +struct FormatSpec<'a> { + flags: &'a str, + width: Option, + index: usize, +} + +fn scan_format_spec(format_string: &str, start: usize) -> FormatSpec<'_> { + let bytes = format_string.as_bytes(); + let mut index = start + 1; + let flags_start = index; + while bytes + .get(index) + .is_some_and(|byte| b"_0^#+-".contains(byte)) + { + index += 1; + } + let width_start = index; + while bytes.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + + let width_text = &format_string[width_start..index]; + let width = if width_text.is_empty() { + None + } else { + Some(width_text.parse::().unwrap_or(usize::MAX)) + }; + + FormatSpec { + flags: &format_string[flags_start..width_start], + width, + index, + } +} + +/// Rewrite selected format specifiers while preserving escaped percent signs. +/// Returning `None` leaves an unrecognized specifier for the normal formatter. +fn rewrite_format_specifiers( + format_string: &str, + mut rewrite: impl FnMut(usize, u8, &FormatSpec<'_>) -> Option, +) -> String { + let mut output = String::with_capacity(format_string.len()); + let bytes = format_string.as_bytes(); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] != b'%' { + let ch = format_string[index..].chars().next().unwrap_or_default(); + output.push(ch); + index += ch.len_utf8(); continue; } - match chars.peek() { - Some('s') => { - chars.next(); - out.push_str(&seconds); - } - // Keep `%%` intact so jiff still renders it as a literal percent. - Some('%') => { - chars.next(); - out.push_str("%%"); - } - _ => out.push('%'), + + let start = index; + index += 1; + if bytes.get(index) == Some(&b'%') { + output.push_str("%%"); + index += 1; + continue; + } + + let spec = scan_format_spec(format_string, start); + let Some(&specifier) = bytes.get(spec.index) else { + output.push('%'); + break; + }; + + if let Some(replacement) = rewrite(start, specifier, &spec) { + output.push_str(&replacement); + index = spec.index + 1; + } else { + output.push('%'); + index = start + 1; } } - out + + output +} + +/// Expand `%s` conversions that carry GNU flags or a width before the regular +/// formatter sees them. Other conversions are left intact for jiff. +fn substitute_epoch_seconds_value(fmt: &str, seconds: i64) -> String { + if !fmt.contains('%') || !fmt.contains('s') { + return fmt.to_string(); + } + + rewrite_format_specifiers(fmt, |start, specifier, spec| { + if specifier != b's' { + return None; + } + if spec.width.is_some_and(|width| width > u16::MAX as usize) { + // Leave oversized fields for the normal modifier path, which + // reports the bounded-allocation error instead of allocating. + Some(fmt[start..=spec.index].to_string()) + } else { + Some(format_epoch_seconds(seconds, spec.flags, spec.width)) + } + }) +} + +/// Format epoch seconds while keeping the sign ahead of zero padding and +/// applying GNU's special no-padding and space-padding modes. +fn format_epoch_seconds(seconds: i64, flags: &str, width: Option) -> String { + let Some(width) = width.filter(|_| !flags.ends_with('-')) else { + return seconds.to_string(); + }; + + if flags.ends_with('_') { + format!("{seconds:width$}") + } else { + format!("{seconds:0width$}") + } } /// Remove the `O` strftime modifier from `fmt`. @@ -986,7 +1122,7 @@ fn write_formatted_bytes( /// Set the clock, then echo the resulting date like GNU does (issue #14677). /// Report a set failure first, so it isn't lost when the echo write fails. fn set_and_echo(input: &str, now: &Zoned, settings: &Settings) -> UResult<()> { - match parse_date(input, now, DebugOptions::new(settings.debug, true), false) { + match parse_date(input, now, DebugOptions::new(settings.debug, true)) { Ok(ParsedDateTime::InRange(date)) => { show_if_err!(set_system_datetime(convert_for_set( date.clone(), @@ -999,9 +1135,10 @@ fn set_and_echo(input: &str, now: &Zoned, settings: &Settings) -> UResult<()> { stdout.flush().map_err(DateError::Write)?; Ok(()) } - Ok(ParsedDateTime::Extended(_)) | Err(_) => Err(Box::new(DateError::InvalidDate { - date: input.to_owned(), + Ok(ParsedDateTime::Extended(_)) => Err(Box::new(DateError::InvalidDate { + date: escape_invalid_bytes(input.as_bytes()), })), + Err((input, _)) => Err(Box::new(DateError::InvalidDate { date: input })), } } @@ -1010,6 +1147,7 @@ fn format_extended_default( chunks: &[FormatChunk], config: &Config, output_time_zone: &TimeZone, + plus_extended_year: bool, ) -> Result, String> { // Apply the output timezone to an equivalent in-range instant, then let the // existing formatter handle every field except the real extended year. @@ -1034,47 +1172,42 @@ fn format_extended_default( surrogate.offset().seconds(), ) .map_err(str::to_string)?; - let mut year_replaced = false; + let iso_year = extended_iso_week_year(&output); + // Keep the real timestamp for `%s`; only calendar fields use the surrogate + // year that makes the timezone formatter accept an extended date. let formatted = format_chunks(chunks, |fmt| { // Expand %x, %X and %r first, so that a %Y they hold is replaced too. let fmt = expand_locale_formats(&surrogate, fmt, config, false, 0)?; - let (fmt, replaced) = substitute_extended_year(&fmt, output.year); - year_replaced |= replaced; + let fmt = expand_extended_composites(fmt.as_ref(), output.year, plus_extended_year); + let fmt = substitute_extended_year(&fmt, output.year, iso_year); + let fmt = substitute_epoch_seconds_value(&fmt, date.unix_seconds()); format_date_with_locale_aware_months(&surrogate, &fmt, config, false) })?; - if year_replaced { - Ok(formatted) - } else { - Err("default date format does not contain %Y".to_string()) - } + Ok(formatted) } -fn surrogate_year(year: u32) -> i16 { - // The Gregorian calendar and recurring timezone rules repeat every 400 years. - const BASE: i64 = 9_599; - (BASE + (i64::from(year) - BASE).rem_euclid(400)) as i16 -} - -/// Expand `%Y` to an out-of-range year, reporting whether it occurred at all. -fn substitute_extended_year(format_string: &str, year: u32) -> (String, bool) { - let mut output = String::with_capacity(format_string.len() + 8); +/// Expand composites that embed year fields before extended-year substitution. +/// This lets `%F` and `%c` use the same out-of-range year as their components. +fn expand_extended_composites(format_string: &str, year: u32, plus_extended_year: bool) -> String { + let mut output = String::with_capacity(format_string.len()); let mut chars = format_string.chars().peekable(); - let year = year.to_string(); - let mut replaced = false; while let Some(ch) = chars.next() { if ch != '%' { output.push(ch); continue; } - match chars.next() { Some('%') => output.push_str("%%"), - Some('Y') => { - output.push_str(&year); - replaced = true; + Some('F') => { + if year > 9999 && plus_extended_year { + output.push_str("+%Y-%m-%d"); + } else { + output.push_str("%Y-%m-%d"); + } } + Some('c') => output.push_str("%a %b %e %T %Y"), Some(specifier) => { output.push('%'); output.push(specifier); @@ -1083,7 +1216,83 @@ fn substitute_extended_year(format_string: &str, year: u32) -> (String, bool) { } } - (output, replaced) + output +} + +/// Calculate the ISO week-year separately because the formatter only receives +/// a surrogate in-range date for extended years. +fn extended_iso_week_year(date: &ExtendedDateTime) -> u32 { + let year = date.year; + let weekday = i32::from(date.weekday_monday0()); + let day_of_year = i32::from(date.day_of_year()); + let week = (day_of_year - weekday + 10) / 7; + + if week < 1 { + year.saturating_sub(1) + } else if week > i32::from(iso_weeks_in_year(year)) { + year.saturating_add(1) + } else { + year + } +} + +fn iso_weeks_in_year(year: u32) -> u8 { + ExtendedDateTime::new( + parse_datetime::DateParts { + year, + month: 1, + day: 1, + }, + parse_datetime::TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .map_or(52, |date| { + let weekday = date.weekday_monday0(); + if weekday == 3 || (weekday == 2 && is_leap_year(year)) { + 53 + } else { + 52 + } + }) +} + +fn surrogate_year(year: u32) -> i16 { + // The Gregorian calendar and recurring timezone rules repeat every 400 years. + const BASE: i64 = 9_599; + (BASE + (i64::from(year) - BASE).rem_euclid(400)) as i16 +} + +fn is_leap_year(year: u32) -> bool { + (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) +} + +/// Expand year conversions to an out-of-range year. +fn substitute_extended_year(format_string: &str, year: u32, iso_year: u32) -> String { + rewrite_format_specifiers(format_string, |start, specifier, spec| { + let (value, default_width) = (match specifier { + b'Y' => Some((year, 4)), + b'C' => Some((year / 100, 2)), + b'G' => Some((iso_year, 4)), + b'g' => Some((iso_year % 100, 2)), + _ => None, + })?; + + if spec.width.is_some_and(|width| width > u16::MAX as usize) { + return Some(format_string[start..=spec.index].to_string()); + } + + Some(format_extended_year_value( + value, + default_width, + spec.flags, + spec.width, + )) + }) } /// Expand `%x`, `%X` and `%r` to the locale's formats, which jiff hardcodes to @@ -1166,6 +1375,47 @@ fn expand_locale_formats<'a>( Ok(Cow::Owned(output)) } +fn format_extended_year_value( + value: u32, + default_width: usize, + flags: &str, + width: Option, +) -> String { + let mut digits = value.to_string(); + let force_sign = flags.contains('+'); + let add_sign = force_sign && (width.is_some() || digits.len() > default_width); + let mut pad = format_modifiers::Pad::Zero; + for flag in flags.chars() { + match flag { + '-' => pad = format_modifiers::Pad::None, + '_' => pad = format_modifiers::Pad::Space, + '0' | '+' => pad = format_modifiers::Pad::Zero, + _ => {} + } + } + if add_sign { + digits.insert(0, '+'); + } + let Some(width) = width else { + return digits; + }; + if matches!(pad, format_modifiers::Pad::None) || width <= digits.len() { + return digits; + } + + let padding = width - digits.len(); + if matches!(pad, format_modifiers::Pad::Zero) && digits.starts_with('+') { + format!("+{}{}", "0".repeat(padding), &digits[1..]) + } else { + let pad_char = match pad { + format_modifiers::Pad::Space => ' ', + format_modifiers::Pad::Zero => '0', + format_modifiers::Pad::None => unreachable!(), + }; + format!("{}{digits}", pad_char.to_string().repeat(padding)) + } +} + fn format_date_with_locale_aware_months( date: &Zoned, format_string: &str, @@ -1380,7 +1630,6 @@ fn parse_dates_from_reader( reader: R, now: &Zoned, dbg_opts: DebugOptions, - allow_extended: bool, ) -> Box< dyn Iterator> + '_, > { @@ -1395,7 +1644,7 @@ fn parse_dates_from_reader( bytes.pop(); } match String::from_utf8(bytes) { - Ok(s) => parse_date(s, now, dbg_opts, allow_extended), + Ok(s) => parse_date(s, now, dbg_opts), // Report lines with invalid UTF-8 (with non-printable bytes // octal-escaped like GNU) instead of silently stopping the input Err(e) => Err(( @@ -1406,21 +1655,525 @@ fn parse_dates_from_reader( })) } +/// Apply a token rewrite without changing the whitespace around each token. +/// Returning `None` avoids allocating when no token matched. +fn rewrite_whitespace_tokens( + input: &str, + mut rewrite: impl FnMut(&str) -> Option, +) -> Option { + let mut output = String::with_capacity(input.len()); + let mut changed = false; + let bytes = input.as_bytes(); + let mut start = 0; + + while start < bytes.len() { + let whitespace = bytes[start].is_ascii_whitespace(); + let mut end = start + 1; + while end < bytes.len() && bytes[end].is_ascii_whitespace() == whitespace { + end += 1; + } + + let part = &input[start..end]; + if whitespace { + output.push_str(part); + } else if let Some(replacement) = rewrite(part) { + output.push_str(&replacement); + changed = true; + } else { + output.push_str(part); + } + start = end; + } + + changed.then_some(output) +} + +/// Convert the European `DD.MM[.][YYYY]` spelling to the slash-separated form +/// understood by the underlying parser, preserving a missing year. +fn rewrite_european_date_token(token: &str) -> Option { + let trailing_dot = token.ends_with('.'); + let core = token.strip_suffix('.').unwrap_or(token); + let parts: Vec<&str> = core.split('.').collect(); + let (day, month, year) = match parts.as_slice() { + [day, month, year] + if day.chars().all(|c| c.is_ascii_digit()) + && month.chars().all(|c| c.is_ascii_digit()) + && year.chars().all(|c| c.is_ascii_digit()) => + { + (*day, *month, Some(*year)) + } + [day, month] + if trailing_dot + && day.chars().all(|c| c.is_ascii_digit()) + && month.chars().all(|c| c.is_ascii_digit()) => + { + (*day, *month, None) + } + _ => return None, + }; + + let day_number = day.parse::().ok()?; + let month_number = month.parse::().ok()?; + if !(1..=31).contains(&day_number) || !(1..=12).contains(&month_number) { + return None; + } + + Some(match year { + Some(year) => format!("{month}/{day}/{year}"), + None => format!("{month}/{day}"), + }) +} + +/// Normalize European dates and the compact times commonly following them. +fn normalize_european_dates(input: &str) -> Option { + let rewritten = rewrite_whitespace_tokens(input, rewrite_european_date_token)?; + let has_short_date = input.split_whitespace().any(|token| { + let core = token.strip_suffix('.').unwrap_or(token); + core.split('.').count() == 2 && token.ends_with('.') + }); + if !has_short_date { + return Some(rewritten); + } + + let mut after_date = false; + let mut after_year = false; + let time_rewritten = rewrite_whitespace_tokens(&rewritten, |token| { + if token.split_once('/').is_some_and(|(month, day)| { + month.chars().all(|c| c.is_ascii_digit()) && day.chars().all(|c| c.is_ascii_digit()) + }) { + after_date = true; + after_year = false; + return None; + } + if after_date && token.len() <= 4 && token.chars().all(|c| c.is_ascii_digit()) { + let value = token.parse::().ok()?; + if token.len() == 4 && value >= 1000 { + after_date = false; + after_year = true; + return None; + } + if value <= 2359 && (token.len() <= 2 || value % 100 < 60) { + after_date = false; + after_year = false; + return Some(if token.len() <= 2 { + format!("{value:02}:00") + } else { + format!("{:02}:{:02}", value / 100, value % 100) + }); + } + } + if after_year && is_clock_with_seconds(token) { + after_year = false; + return None; + } + after_date = false; + after_year = false; + None + }); + time_rewritten.or(Some(rewritten)) +} + +fn is_weekday_name(token: &str) -> bool { + // GNU documents these forms in its "Day of week items" section: + // https://www.gnu.org/software/coreutils/manual/html_node/Day-of-week-items.html + // Full names, three-letter abbreviations with an optional period, and the + // special `tues`, `wednes`, `thur`, and `thurs` forms are accepted. Single- + // letter forms are intentionally excluded because they are ambiguous with + // other syntax. + matches!( + token.to_ascii_lowercase().as_str(), + "monday" + | "mon" + | "mon." + | "tuesday" + | "tue" + | "tue." + | "tues" + | "wednesday" + | "wed" + | "wed." + | "wednes" + | "thursday" + | "thu" + | "thu." + | "thur" + | "thurs" + | "friday" + | "fri" + | "fri." + | "saturday" + | "sat" + | "sat." + | "sunday" + | "sun" + | "sun." + ) +} + +fn looks_like_explicit_calendar_date(input: &str) -> bool { + let Some(token) = input.split_whitespace().next() else { + return false; + }; + if token.starts_with(['+', '-']) { + return false; + } + let separators = token + .chars() + .filter(|c| matches!(c, '-' | '/' | '.')) + .count(); + separators >= 2 + || token.contains('/') + || (token.chars().all(|c| c.is_ascii_digit()) && token.len() >= 6) +} + +fn is_weekday_prefix(token: &str) -> bool { + token.parse::().is_ok() + || matches!( + token.to_ascii_lowercase().as_str(), + "first" + | "second" + | "third" + | "fourth" + | "fifth" + | "sixth" + | "seventh" + | "eighth" + | "ninth" + | "tenth" + | "eleventh" + | "twelfth" + | "last" + | "next" + | "this" + ) +} + +/// Drop weekday items when an explicit calendar date is present. +/// Relative-only inputs must remain untouched because the weekday can matter. +fn strip_weekday_for_explicit_date(input: &str) -> Option { + let mut spans = Vec::new(); + let mut token_start = None; + for (index, character) in input.char_indices() { + if character.is_whitespace() { + if let Some(start) = token_start.take() { + spans.push((start, index)); + } + } else if token_start.is_none() { + token_start = Some(index); + } + } + if let Some(start) = token_start { + spans.push((start, input.len())); + } + + let tokens: Vec<&str> = spans + .iter() + .map(|&(start, end)| &input[start..end]) + .collect(); + let date_index = tokens.iter().position(|token| { + !token.starts_with("TZ=\"") + && !token.ends_with(',') + && looks_like_explicit_calendar_date(token) + })?; + let weekday_index = tokens.iter().enumerate().find_map(|(index, token)| { + let weekday = token.strip_suffix(',').unwrap_or(token); + (index != date_index && is_weekday_name(weekday)).then_some(index) + })?; + + let mut removed = vec![false; tokens.len()]; + removed[weekday_index] = true; + if weekday_index > 0 + && weekday_index - 1 != date_index + && is_weekday_prefix(tokens[weekday_index - 1]) + { + removed[weekday_index - 1] = true; + } + + let mut result = String::with_capacity(input.len()); + let mut cursor = 0; + for (index, &(start, end)) in spans.iter().enumerate() { + result.push_str(&input[cursor..start]); + if !removed[index] { + result.push_str(&input[start..end]); + } + cursor = end; + } + result.push_str(&input[cursor..]); + Some(result) +} + +/// Normalize offsets whose minute component exceeds 59 into total minutes. +fn rewrite_large_offset_token(token: &str) -> Option { + if !token.starts_with(['+', '-']) { + return None; + } + let (sign, rest) = token.split_at(1); + let (hours, minutes) = rest.split_once(':')?; + if hours.is_empty() + || minutes.is_empty() + || !hours.chars().all(|c| c.is_ascii_digit()) + || !minutes.chars().all(|c| c.is_ascii_digit()) + { + return None; + } + let hours = hours.parse::().ok()?; + let minutes = minutes.parse::().ok()?; + if minutes < 60 { + return None; + } + let total_minutes = hours.checked_mul(60)?.checked_add(minutes)?; + Some(format!( + "{sign}{:02}:{:02}", + total_minutes / 60, + total_minutes % 60 + )) +} + +fn normalize_large_offsets(input: &str) -> Option { + rewrite_whitespace_tokens(input, rewrite_large_offset_token) +} + +fn is_clock_with_seconds(token: &str) -> bool { + let mut parts = token.split(':'); + let (Some(hour), Some(minute), Some(second)) = (parts.next(), parts.next(), parts.next()) + else { + return false; + }; + if parts.next().is_some() || hour.is_empty() || minute.is_empty() || second.is_empty() { + return false; + } + + let valid_second = match second.split_once('.') { + Some((seconds, fraction)) => { + !seconds.is_empty() + && !fraction.is_empty() + && seconds.chars().all(|c| c.is_ascii_digit()) + && fraction.chars().all(|c| c.is_ascii_digit()) + } + None => second.chars().all(|c| c.is_ascii_digit()), + }; + + hour.chars().all(|c| c.is_ascii_digit()) + && minute.chars().all(|c| c.is_ascii_digit()) + && valid_second +} + +/// Parse the ambiguous `+N unit` form after an absolute time using the same +/// parser for its timezone offset and relative item, rather than rewriting the +/// input into unrelated syntax. +fn parse_relative_plus(input: &str, now: &Zoned) -> Option { + let words: Vec<&str> = input.split_whitespace().collect(); + if !words.iter().any(|word| is_clock_with_seconds(word)) + || words.iter().any(|word| { + word.eq_ignore_ascii_case("utc") + || word.eq_ignore_ascii_case("gmt") + || word.starts_with("TZ=\"") + }) + { + return None; + } + + let relative_marker = words.iter().position(|word| { + matches!( + word.to_ascii_lowercase().as_str(), + "today" | "tomorrow" | "yesterday" | "now" + ) + }); + for (index, pair) in words.windows(2).enumerate() { + let number = pair[0]; + let unit = pair[1]; + let Some(number) = number.strip_prefix('+') else { + continue; + }; + if number.is_empty() + || !number.chars().all(|c| c.is_ascii_digit()) + || !unit.chars().all(|c| c.is_ascii_alphabetic()) + || relative_marker.is_some_and(|marker| index >= marker) + { + continue; + } + + let mut base_input = String::with_capacity(input.len()); + for (word_index, word) in words.iter().enumerate() { + if word_index == index + 1 { + continue; + } + if !base_input.is_empty() { + base_input.push(' '); + } + base_input.push_str(word); + } + + let Some(base) = parse_datetime::parse_datetime_at_date(now.clone(), base_input) + .ok() + .and_then(ParsedDateTime::into_zoned) + else { + continue; + }; + if let Ok(parsed) = parse_datetime::parse_datetime_at_date(base, unit) { + return Some(parsed); + } + } + + None +} + +/// Apply only the targeted syntax rewrites needed before parse_datetime. +/// Keeping the input borrowed when possible also preserves the original text +/// for diagnostics. +fn normalize_parse_input(input: &str) -> Cow<'_, str> { + let mut current = Cow::Borrowed(input); + + for rewrite in [ + strip_weekday_for_explicit_date, + normalize_european_dates, + normalize_large_offsets, + ] { + if let Some(rewritten) = rewrite(current.as_ref()) { + current = Cow::Owned(rewritten); + } + } + current +} + +/// Extract a local wall-clock value for the DST-gap check and its diagnostics. +/// This is intentionally narrower than the full date parser. +fn parse_local_datetime(input: &str) -> Option { + let normalized = input.replace(['T', 't'], " "); + let (year, month, day) = normalized.split_whitespace().find_map(|token| { + let parts: Vec<&str> = if token.contains('-') { + token.split('-').collect() + } else if token.contains('/') { + token.split('/').collect() + } else { + return None; + }; + let (year, month, day) = match parts.as_slice() { + [year, month, day] if token.contains('-') => (*year, *month, *day), + [month, day, year] if token.contains('/') => (*year, *month, *day), + _ => return None, + }; + if !year.chars().all(|c| c.is_ascii_digit()) + || !month.chars().all(|c| c.is_ascii_digit()) + || !day.chars().all(|c| c.is_ascii_digit()) + { + return None; + } + Some(( + year.parse::().ok()?, + month.parse::().ok()?, + day.parse::().ok()?, + )) + })?; + let (hour, minute, second, nanosecond) = normalized.split_whitespace().find_map(|token| { + let token = token + .char_indices() + .skip(1) + .find(|&(index, ch)| { + (ch == '+' || ch == '-') + && token[..index].contains(':') + && is_numeric_timezone(&token[index..]) + }) + .map_or(token, |(index, _)| &token[..index]); + let mut parts = token.split(':'); + let (Some(hour), Some(minute)) = (parts.next(), parts.next()) else { + return None; + }; + let second = parts.next().unwrap_or("0"); + if parts.next().is_some() { + return None; + } + let (second, fraction) = second.split_once('.').unwrap_or((second, "")); + let fraction = if fraction.is_empty() { + 0 + } else { + let mut digits = fraction.as_bytes().to_vec(); + digits.truncate(9); + while digits.len() < 9 { + digits.push(b'0'); + } + std::str::from_utf8(&digits).ok()?.parse::().ok()? + }; + Some(( + hour.parse::().ok()?, + minute.parse::().ok()?, + second.parse::().ok()?, + fraction, + )) + })?; + jiff::civil::DateTime::new(year, month, day, hour, minute, second, nanosecond).ok() +} + +fn input_timezone(input: &str, now: &Zoned) -> TimeZone { + let trimmed = input.trim_start(); + if let Some(rest) = trimmed.strip_prefix("TZ=\"") + && let Some(end) = rest.find('"') + { + let name = &rest[..end]; + TimeZone::get(name) + .or_else(|_| TimeZone::posix(name)) + .unwrap_or_else(|_| now.time_zone().clone()) + } else { + now.time_zone().clone() + } +} + +fn has_explicit_numeric_timezone(input: &str) -> bool { + input.split_whitespace().any(|token| { + if is_numeric_timezone(token) { + return true; + } + + token.char_indices().skip(1).any(|(index, ch)| { + (ch == '+' || ch == '-') + && token[..index].contains(':') + && is_numeric_timezone(&token[index..]) + }) + }) +} + +fn is_numeric_timezone(offset: &str) -> bool { + let rest = offset.strip_prefix(['+', '-']).unwrap_or(""); + !rest.is_empty() + && rest.chars().all(|c| c.is_ascii_digit() || c == ':') + && (rest.contains(':') || (1..=4).contains(&rest.len())) +} + +/// Detect a local wall-clock time that falls inside a timezone transition gap. +/// Explicit numeric offsets are absolute instants and therefore cannot be gaps. +fn is_nonexistent_local_time(input: &str, now: &Zoned) -> bool { + if has_explicit_numeric_timezone(input) { + return false; + } + let normalized = normalize_parse_input(input); + let Some(local) = parse_local_datetime(normalized.as_ref()) else { + return false; + }; + matches!( + input_timezone(input, now) + .to_ambiguous_zoned(local) + .offset(), + AmbiguousOffset::Gap { .. } + ) +} + /// Parse a string into either an in-range [`Zoned`] value or an extended date. fn parse_date>( s: S, now: &Zoned, dbg_opts: DebugOptions, - allow_extended: bool, ) -> Result { let input_str = s.as_ref(); - if dbg_opts.debug { let _ = writeln!(stderr(), "date: input string: {input_str}"); } + // Normalize parser-specific compatibility forms before parsing. + let normalized = normalize_parse_input(input_str); + let parse_input = normalized.as_ref(); + // First, try to parse any timezone abbreviations - if let Some(zoned) = try_parse_with_abbreviation(input_str, now) { + if let Some(zoned) = try_parse_with_abbreviation(parse_input, now) { if dbg_opts.debug { let mut err = stderr().lock(); let _ = writeln!( @@ -1439,10 +2192,23 @@ fn parse_date>( return Ok(ParsedDateTime::InRange(zoned)); } - match parse_datetime::parse_datetime_at_date(now.clone(), input_str) { + let parsed = parse_relative_plus(parse_input, now).map_or_else( + || parse_datetime::parse_datetime_at_date(now.clone(), parse_input), + Ok, + ); + + match parsed { // Convert to system timezone for display // (parse_datetime returns a value in the input's timezone) Ok(ParsedDateTime::InRange(date)) => { + // parse_datetime may normalize a nonexistent wall-clock time; + // reject that result so DST gaps are not silently accepted. + if is_nonexistent_local_time(input_str, now) { + return Err(( + escape_invalid_bytes(input_str.as_bytes()), + parse_datetime::ParseDateTimeError::InvalidInput, + )); + } let result = date.timestamp().to_zoned(now.time_zone().clone()); if dbg_opts.debug { // Show final parsed date and time @@ -1477,12 +2243,8 @@ fn parse_date>( } Ok(ParsedDateTime::InRange(result)) } - Ok(ParsedDateTime::Extended(date)) if allow_extended => Ok(ParsedDateTime::Extended(date)), - Ok(ParsedDateTime::Extended(_)) => Err(( - input_str.into(), - parse_datetime::ParseDateTimeError::InvalidInput, - )), - Err(e) => Err((input_str.into(), e)), + Ok(ParsedDateTime::Extended(date)) => Ok(ParsedDateTime::Extended(date)), + Err(e) => Err((escape_invalid_bytes(input_str.as_bytes()), e)), } } @@ -1663,14 +2425,9 @@ mod tests { #[test] fn test_abbreviation_resolves_relative_date_against_now() { let now = "2025-03-15T20:00:00+00:00[UTC]".parse::().unwrap(); - let result = parse_date( - "yesterday 10:00 GMT", - &now, - DebugOptions::new(false, false), - false, - ) - .unwrap() - .expect_in_range(); + let result = parse_date("yesterday 10:00 GMT", &now, DebugOptions::new(false, false)) + .unwrap() + .expect_in_range(); assert_eq!(result.date(), jiff::civil::date(2025, 3, 14)); } @@ -1682,7 +2439,6 @@ mod tests { "Sat 20 Mar 2021 14:53:01 AWST", &now, DebugOptions::new(false, false), - false, ) .unwrap() .expect_in_range(); @@ -1690,6 +2446,16 @@ mod tests { assert_eq!((utc.hour(), utc.minute(), utc.second()), (6, 53, 1)); // AWST(+08:00) -> -8h } + #[test] + fn test_is_clock_with_seconds_rejects_empty_components() { + for input in ["::..", "::", ":12:34", "12::34", "12:34:"] { + assert!(!is_clock_with_seconds(input), "unexpected clock: {input}"); + } + for input in ["12:34:56", "12:34:56.789"] { + assert!(is_clock_with_seconds(input), "missing clock: {input}"); + } + } + #[test] fn test_strip_parenthesized_comments() { assert_eq!(strip_parenthesized_comments("hello"), "hello"); diff --git a/src/uu/date/src/format_modifiers.rs b/src/uu/date/src/format_modifiers.rs index a9b4fb14bfa..886be6ee983 100644 --- a/src/uu/date/src/format_modifiers.rs +++ b/src/uu/date/src/format_modifiers.rs @@ -47,6 +47,13 @@ use uucore::translate; /// elsewhere. const MAX_FORMAT_WIDTH: usize = u16::MAX as usize; +#[derive(Clone, Copy)] +pub(super) enum Pad { + None, + Space, + Zero, +} + /// Error type for format modifier operations #[derive(Debug)] pub enum FormatError { @@ -238,7 +245,15 @@ fn format_with_modifiers( let formatted = broken_down.to_string_with_config(config, &base_format)?; if !parsed.flags.is_empty() || parsed.width.is_some() { - let modified = apply_modifiers(&formatted, &parsed)?; + // Composite conversions are already expanded as a whole; + // timezone conversions need sign-aware width handling. + let modified = if is_composite_specifier(parsed.spec) { + apply_composite_modifiers(&formatted, &parsed)? + } else if parsed.spec.ends_with('z') && !parsed.flags.is_empty() { + apply_timezone_modifiers(&formatted, &parsed)? + } else { + apply_modifiers(&formatted, &parsed)? + }; result.push_str(&modified); } else { result.push_str(&formatted); @@ -258,6 +273,147 @@ fn format_with_modifiers( Ok(result) } +/// Composite strftime conversions are expanded atomically by GNU date. In +/// particular, `-` and `_` do not alter the padding of the fields inside an +/// expansion such as `%D` or `%T`; an explicit width applies to the complete +/// expansion instead. +fn is_composite_specifier(specifier: &str) -> bool { + matches!( + specifier, + "c" | "D" | "F" | "R" | "r" | "T" | "x" | "X" | "v" + ) +} + +/// Apply flags and an optional width to the complete composite value rather +/// than changing the padding of the fields inside it. +pub fn apply_composite_modifiers( + value: &str, + parsed: &ParsedSpec<'_>, +) -> Result { + let mut pad = Pad::Space; + let mut uppercase = false; + + for flag in parsed.flags.chars() { + match flag { + '-' => pad = Pad::None, + '_' => pad = Pad::Space, + '0' | '+' => pad = Pad::Zero, + '^' => { + uppercase = true; + } + _ => {} + } + } + + let result = if uppercase { + value.to_uppercase() + } else { + value.to_string() + }; + + if matches!(pad, Pad::None) { + return Ok(result); + } + + let Some(width) = parsed.width else { + return Ok(result); + }; + if width > MAX_FORMAT_WIDTH { + return Err(field_width_too_large(width, parsed.spec)); + } + if width <= result.len() { + return Ok(result); + } + + let padding = width - result.len(); + let mut padded_result = try_alloc_padded(result.len(), padding, width, parsed.spec)?; + let pad_char = match pad { + Pad::Space => ' ', + Pad::Zero => '0', + Pad::None => unreachable!(), + }; + padded_result.extend(std::iter::repeat_n(pad_char, padding)); + padded_result.push_str(&result); + Ok(padded_result) +} + +/// Remove the zero padding from the hour component of a numeric timezone. +/// GNU still keeps the minute and second components, if present. +fn strip_timezone_hour_padding(value: &str) -> String { + if !value.starts_with(['+', '-']) { + return value.to_string(); + } + + let (sign, body) = value.split_at(1); + if body.chars().all(|c| c == '0') { + return format!("{sign}0"); + } + + // `%z` has a four-digit hour/minute form. `%:::z` may instead contain + // only the hour, so both forms are handled here. + let (hour, rest) = match body.find(':') { + Some(i) => (&body[..i], &body[i..]), + None if body.len() >= 4 => (&body[..body.len() - 2], &body[body.len() - 2..]), + None => (body, ""), + }; + + let hour = hour.trim_start_matches('0'); + let hour = if hour.is_empty() { "0" } else { hour }; + format!("{sign}{hour}{rest}") +} + +/// Return the natural width of each timezone spelling before modifiers apply. +fn timezone_default_width(specifier: &str) -> usize { + match specifier { + "z" => 5, + ":z" => 6, + "::z" => 9, + ":::z" => 3, + _ => 0, + } +} + +/// Apply modifiers to timezone offsets without separating a sign from its +/// numeric field or losing the variable-width colon forms. +fn apply_timezone_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result { + let mode = parsed + .flags + .chars() + .rfind(|&flag| matches!(flag, '-' | '_' | '0' | '+')); + + let result = if matches!(mode, Some('-' | '_')) { + strip_timezone_hour_padding(value) + } else { + value.to_string() + }; + + if mode == Some('-') { + return Ok(result); + } + + let width = parsed + .width + .unwrap_or_else(|| timezone_default_width(parsed.spec)); + if width > MAX_FORMAT_WIDTH { + return Err(field_width_too_large(width, parsed.spec)); + } + if width <= result.len() { + return Ok(result); + } + + let padding = width - result.len(); + let mut padded = try_alloc_padded(result.len(), padding, width, parsed.spec)?; + if mode == Some('0') && result.starts_with(['+', '-']) { + padded.push_str(&result[..1]); + padded.extend(std::iter::repeat_n('0', padding)); + padded.push_str(&result[1..]); + } else { + padded.extend(std::iter::repeat_n(' ', padding)); + padded.push_str(&result); + } + Ok(padded) +} + /// Returns true if the specifier produces text output (default pad is space) /// rather than numeric output (default pad is zero). fn is_text_specifier(specifier: &str) -> bool { @@ -429,6 +585,17 @@ pub fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result MAX_FORMAT_WIDTH { + return Err(field_width_too_large(width, specifier)); + } + if width < result.len() { + result.truncate(width); + } + } + return Ok(result); + } return Ok(strip_default_padding(&result)); } @@ -482,7 +649,13 @@ pub fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result) -> Result, -) -> Result { - let mut pad_char = Some(' '); - let mut uppercase = false; - for flag in parsed.flags.chars() { - match flag { - '-' => pad_char = None, - '_' => pad_char = Some(' '), - '0' | '+' => pad_char = Some('0'), - '^' => uppercase = true, - _ => {} - } - } - - let value = if uppercase { - value.to_uppercase() - } else { - value.to_string() - }; - // `-` drops the padding and with it the width, however large. - let Some(pad_char) = pad_char else { - return Ok(value); - }; - let width = parsed.width.unwrap_or(0); - if width > MAX_FORMAT_WIDTH { - return Err(field_width_too_large(width, parsed.spec)); - } - if width <= value.len() { - return Ok(value); - } - let padding = width - value.len(); - let mut padded = try_alloc_padded(value.len(), padding, width, parsed.spec)?; - padded.extend(std::iter::repeat_n(pad_char, padding)); - padded.push_str(&value); - Ok(padded) -} - /// Allocate a `String` with enough capacity for `current_len + padding`, /// returning `FieldWidthTooLarge` on arithmetic overflow or allocation failure. fn try_alloc_padded( diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index b4283036763..5c477dadde6 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST MESZ KST uueuu ueuu vasárnap június január distros +// spell-checker:ignore AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST MESZ KST uueuu ueuu vasárnap június január distros Wednes // spell-checker:ignore uppercases xffx use std::cmp::Ordering; @@ -46,7 +46,7 @@ fn test_bad_format_option_missing_leading_plus_after_d_flag() { new_ucmd!() .args(&["--date", "1996-01-31", bad_argument]) .fails_with_code(1) - .stderr_contains(format!("the argument {bad_argument} lacks a leading '+';\nwhen using an option to specify date(s), any non-option\nargument must be a format string beginning with '+'"), ); + .stderr_contains(format!("the argument '{bad_argument}' lacks a leading '+';\nwhen using an option to specify date(s), any non-option\nargument must be a format string beginning with '+'"), ); } } @@ -101,6 +101,44 @@ fn test_large_year_default_output_boundary() { .stderr_contains("invalid date"); } +#[test] +fn test_large_year_custom_formats_match_gnu() { + for (format, expected) in [ + ("+%F", "+10000-01-01\n"), + ("+%s", "253402300800\n"), + ("+%C", "100\n"), + ("+%G", "9999\n"), + ("+%10Y", "0000010000\n"), + ("+%_10Y", " 10000\n"), + ("+%-10Y", "10000\n"), + ("+%10C", "0000000100\n"), + ("+%10G", "0000009999\n"), + ("+%c", "Sat Jan 1 00:00:00 10000\n"), + ("+hello", "hello\n"), + ] { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC") + .args(&["-d", "10000-01-01", format]) + .succeeds() + .stdout_is(expected); + } + + for args in [ + vec!["-I", "-d", "10000-01-01"], + vec!["--rfc-3339=date", "-d", "10000-01-01"], + ] { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC") + .args(&args) + .succeeds() + .stdout_is("10000-01-01\n"); + } +} + #[test] fn test_date_rejects_input_that_cannot_take_a_timezone() { // A trailing timezone abbreviation must not rescue an input that already @@ -424,6 +462,71 @@ fn test_date_utc_output_formats() { } } +#[test] +fn test_date_rejects_multiple_output_formats() { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .args(&["-I", "-d", "2024-01-01", "+%F"]) + .fails_with_code(1) + .no_stdout() + .stderr_is("date: multiple output formats specified\n"); +} + +#[test] +fn test_date_missing_format_plus_precedes_format_conflict() { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .args(&["-I", "-d", "2024-01-01", "%Y"]) + .fails_with_code(1) + .stderr_contains("the argument '%Y' lacks a leading '+'"); +} + +#[test] +fn test_date_iso_numeric_timezone() { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "America/New_York") + .args(&["-d", "2024-03-10T02:30+0", "+%F %T %z"]) + .succeeds() + .stdout_is("2024-03-09 21:30:00 -0500\n"); +} + +#[test] +#[cfg(unix)] +fn test_date_rejects_nonexistent_dst_inputs() { + for input in ["2024-03-10 02:30", "03/10/2024 02:30", "10.03.2024 02:30"] { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "America/New_York") + .args(&["-d", input, "+%F %T %Z"]) + .fails_with_code(1) + .stderr_contains("invalid date"); + } +} + +#[test] +fn test_date_relative_plus_after_today() { + for (input, expected) in [ + ("2024-06-15 12:00:00 today +1 hour", "2024-06-15 13:00:00\n"), + ("2024-06-15 12:00:00 +1 hour", "2024-06-15 12:00:00\n"), + ("2024-06-15 12:00:00 +1 hour today", "2024-06-15 12:00:00\n"), + ("2024-06-15 12:00:00 +1 day", "2024-06-16 11:00:00\n"), + ("2024-06-15 12:00:00 +2 minutes", "2024-06-15 10:01:00\n"), + ] { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC") + .args(&["-d", input, "+%F %T"]) + .succeeds() + .stdout_is(expected); + } +} + #[test] fn test_date_utc_stdin() { new_ucmd!() @@ -536,6 +639,16 @@ fn test_date_set_invalid() { assert!(result.stderr_str().starts_with("date: invalid date ")); } +#[test] +fn test_date_set_invalid_date_uses_locale_escaping() { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .args(&["--set", "−1 day"]) + .fails_with_code(1) + .stderr_contains("invalid date '\\342\\210\\2221 day'"); +} + #[test] fn test_date_error_echoes_input_verbatim() { // Error messages echo what the user typed: numeric-looking input must @@ -551,7 +664,7 @@ fn test_date_error_echoes_input_verbatim() { new_ucmd!() .args(&["--date", "1996-01-31", "1e9"]) .fails_with_code(1) - .stderr_contains("the argument 1e9 lacks a leading '+'"); + .stderr_contains("the argument '1e9' lacks a leading '+'"); } #[test] @@ -994,6 +1107,37 @@ fn test_relative_weekdays() { } } +#[test] +fn test_weekday_items_are_ignored_with_explicit_dates() { + for input in [ + "Monday 2024-06-15", + "Mon. 2024-06-15", + "Tues 2024-06-15", + "Wednes 2024-06-15", + "Thur 2024-06-15", + "Thurs 2024-06-15", + "Monday, 2024-06-15", + "next Monday 2024-06-15", + "last Monday 2024-06-15", + "2 Monday 2024-06-15", + "third Monday 2024-06-15", + "2024-06-15 Monday", + "2024-06-15 Monday,", + "2024-06-15 next Monday", + "2024-06-15 last Monday", + "2024-06-15 2 Monday", + "2024-06-15 third Monday", + ] { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC") + .args(&["-d", input, "+%F"]) + .succeeds() + .stdout_is("2024-06-15\n"); + } +} + #[test] fn test_invalid_date_string() { new_ucmd!() @@ -2243,6 +2387,10 @@ fn test_date_format_locale_date_and_time_no_pad_large_width() { /// values are GNU date's, the macOS ones follow its locale data. #[test] #[cfg(any(target_vendor = "apple", all(target_os = "linux", target_env = "gnu")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_format_locale_ampm_markers() { #[cfg(target_os = "linux")] let cases = [ @@ -2344,6 +2492,8 @@ fn test_date_negative_fractional_epoch_flooring() { ("@-0.25", "+%s", "-1\n"), ("@-2.75", "+%s.%N", "-3.250000000\n"), ("@-100.5", "+%s", "-101\n"), + ("@-0.5", "+%5s", "-0001\n"), + ("@-0.5", "+%_5s", " -1\n"), // Positive fractions and whole seconds are unaffected. ("@42.9", "+%s", "42\n"), ("@-7", "+%s", "-7\n"), @@ -2418,9 +2568,8 @@ fn test_date_strftime_case_flag_on_alt_ampm() { } #[test] -#[ignore = "https://github.com/uutils/coreutils/issues/11658 — GNU date applies flags/widths to `%N` (nanoseconds); uutils ignores/mishandles them."] fn test_date_strftime_n_width_and_flags() { - // `%_3N` should space-pad nanoseconds to width 3. GNU outputs `0 `; uutils outputs `0`. + // `%_3N` should space-pad nanoseconds to width 3. GNU outputs `0 `. new_ucmd!() .env("LC_ALL", "C") .env("TZ", "UTC") @@ -2430,7 +2579,7 @@ fn test_date_strftime_n_width_and_flags() { .succeeds() .stdout_is("0 \n"); // `%-N` (no-padding flag) should still output the full 9-digit default. - // GNU: `000000000`; uutils: `0`. + // GNU outputs `000000000`. new_ucmd!() .env("LC_ALL", "C") .env("TZ", "UTC") @@ -2442,18 +2591,40 @@ fn test_date_strftime_n_width_and_flags() { } #[test] -#[ignore = "https://github.com/uutils/coreutils/issues/11657 — GNU date treats composite strftime specifiers (%D, %F, %T, ...) as atomic; flags like `-` should not propagate to sub-fields."] fn test_date_strftime_flag_on_composite() { - // GNU `%-D` keeps `06/15/24` (flag ignored on composite). - // uutils applies `-` to inner `%m`, producing `6/15/24`. - new_ucmd!() - .env("LC_ALL", "C") - .env("TZ", "UTC") - .arg("-d") - .arg("2024-06-15") - .arg("+%-D") - .succeeds() - .stdout_is("06/15/24\n"); + // GNU treats composite strftime specifiers atomically. + for (format, expected) in [ + ("+%-D", "06/15/24\n"), + ("+%-30c", "Sat Jun 15 00:00:00 2024\n"), + ("+%#c", "Sat Jun 15 00:00:00 2024\n"), + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .arg("-d") + .arg("2024-06-15") + .arg(format) + .succeeds() + .stdout_is(expected); + } +} + +#[test] +fn test_date_strftime_timezone_flag_precedence() { + for (format, expected) in [ + ("+%_0z", "+0000\n"), + ("+%-_z", " +0\n"), + ("+%0-z", "+0\n"), + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .arg("-d") + .arg("2024-06-15") + .arg(format) + .succeeds() + .stdout_is(expected); + } } #[test] @@ -2471,10 +2642,8 @@ fn test_date_strftime_o_modifier() { } #[test] -#[ignore = "https://github.com/uutils/parse_datetime/issues/280 — GNU date accepts bare timezone abbreviations (UT, GMT, ...) meaning `now in that TZ`; parse_datetime rejects them."] fn test_date_bare_timezone_abbreviation() { - // GNU: `date -d ut`, `date -d UT`, `date -d gmt` → current time in UTC. - // uutils: "invalid date" error. + // GNU and uutils accept bare UT/GMT abbreviations as UTC. for input in ["ut", "UT", "gmt", "GMT"] { new_ucmd!() .env("TZ", "UTC+1") @@ -2487,10 +2656,9 @@ fn test_date_bare_timezone_abbreviation() { } #[test] -#[ignore = "https://github.com/uutils/parse_datetime/issues/279. GNU date silently ignores unrecognized trailing tokens (e.g. `8 j`), but parse_datetime rejects them."] fn test_date_ignores_unrecognized_trailing_tokens() { // GNU compatibility: trailing unknown word-tokens after a valid number are ignored. - // GNU parses `8 j` (number, space, token) as hour 8; our parse_datetime crate errors out. + // `8 j` (number, space, token) is parsed as hour 8. // The no-space `8j` form is handled directly (see test_date_military_timezone_j_with_time). new_ucmd!() .env("TZ", "UTC") @@ -3630,15 +3798,16 @@ fn test_write_error() { } #[test] -#[ignore = "GNU compat: see uutils/coreutils#14648"] fn test_date_allow_missing_year() { - new_ucmd!().arg("01.01. 03:00 p.m.").succeeds(); + new_ucmd!().arg("-d").arg("01.01. 03:00 p.m.").succeeds(); } #[test] -#[ignore = "GNU compat: see uutils/coreutils#14649"] fn test_date_allow_spaces_after_month() { - new_ucmd!().arg("01.01. 2008 03:00 p.m.").succeeds(); + new_ucmd!() + .arg("-d") + .arg("01.01. 2008 03:00 p.m.") + .succeeds(); } #[test] @@ -3711,7 +3880,7 @@ fn test_non_utf8_operands_are_octal_escaped() { (&[b"+%Y", b"\xf1ao"], "extra operand '\\361ao'"), ( &[b"-d", b"2031-07-23", b"%Y\xd8"], - "the argument %Y\\330 lacks a leading '+'", + "the argument '%Y\\330' lacks a leading '+'", ), ]; @@ -3727,3 +3896,19 @@ fn test_non_utf8_operands_are_octal_escaped() { .stderr_contains(expected); } } + +#[test] +#[cfg(unix)] +fn test_date_c_locale_escapes_non_ascii_operands() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let input = OsString::from_vec("−1 day".as_bytes().to_vec()); + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .arg("-d") + .arg(input) + .fails() + .stderr_contains("invalid date '\\342\\210\\2221 day'"); +}