Skip to content

Commit 92ba14f

Browse files
committed
date: handle parentheses as comments like GNU date
1 parent ac2707b commit 92ba14f

File tree

2 files changed

+122
-1
lines changed

2 files changed

+122
-1
lines changed

src/uu/date/src/date.rs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
55

6-
// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST
6+
// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz
77

88
mod locale;
99

1010
use clap::{Arg, ArgAction, Command};
1111
use jiff::fmt::strtime;
1212
use jiff::tz::{TimeZone, TimeZoneDatabase};
1313
use jiff::{Timestamp, Zoned};
14+
use std::borrow::Cow;
1415
use std::collections::HashMap;
1516
use std::fs::File;
1617
use std::io::{BufRead, BufReader, BufWriter, Write};
@@ -130,6 +131,52 @@ enum DayDelta {
130131
Next,
131132
}
132133

134+
/// Strip parenthesized comments from a date string.
135+
///
136+
/// GNU date removes balanced parentheses and their content, treating them as comments.
137+
/// If parentheses are unbalanced, everything from the unmatched '(' onwards is ignored.
138+
///
139+
/// Examples:
140+
/// - "2026(comment)-01-05" -> "2026-01-05"
141+
/// - "1(ignore comment to eol" -> "1"
142+
/// - "(" -> ""
143+
/// - "((foo)2026-01-05)" -> ""
144+
fn strip_parenthesized_comments(input: &str) -> Cow<'_, str> {
145+
if !input.contains('(') {
146+
return Cow::Borrowed(input);
147+
}
148+
149+
let mut result = String::with_capacity(input.len());
150+
let mut chars = input.chars();
151+
152+
while let Some(c) = chars.next() {
153+
if c == '(' {
154+
// Look for matching closing parenthesis
155+
let mut depth = 1;
156+
for inner_c in chars.by_ref() {
157+
if inner_c == '(' {
158+
depth += 1;
159+
} else if inner_c == ')' {
160+
depth -= 1;
161+
if depth == 0 {
162+
break;
163+
}
164+
}
165+
}
166+
167+
// If unmatched opening paren (depth > 0), stop processing entirely
168+
if depth > 0 {
169+
break;
170+
}
171+
// If balanced, the parentheses and their content are skipped (comment)
172+
} else {
173+
result.push(c);
174+
}
175+
}
176+
177+
Cow::Owned(result)
178+
}
179+
133180
/// Parse military timezone with optional hour offset.
134181
/// Pattern: single letter (a-z except j) optionally followed by 1-2 digits.
135182
/// Returns Some(total_hours_in_utc) or None if pattern doesn't match.
@@ -286,7 +333,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
286333
// Iterate over all dates - whether it's a single date or a file.
287334
let dates: Box<dyn Iterator<Item = _>> = match settings.date_source {
288335
DateSource::Human(ref input) => {
336+
// GNU compatibility (Comments in parentheses)
337+
let input = strip_parenthesized_comments(input);
289338
let input = input.trim();
339+
290340
// GNU compatibility (Empty string):
291341
// An empty string (or whitespace-only) should be treated as midnight today.
292342
let is_empty_or_whitespace = input.is_empty();
@@ -885,4 +935,38 @@ mod tests {
885935
assert_eq!(parse_military_timezone_with_offset("m999"), None); // Too long
886936
assert_eq!(parse_military_timezone_with_offset("9m"), None); // Starts with digit
887937
}
938+
939+
#[test]
940+
fn test_strip_parenthesized_comments() {
941+
assert_eq!(strip_parenthesized_comments("hello"), "hello");
942+
assert_eq!(strip_parenthesized_comments("2026-01-05"), "2026-01-05");
943+
assert_eq!(strip_parenthesized_comments("("), "");
944+
assert_eq!(strip_parenthesized_comments("1(comment"), "1");
945+
assert_eq!(
946+
strip_parenthesized_comments("2026-01-05(this is a comment"),
947+
"2026-01-05"
948+
);
949+
assert_eq!(
950+
strip_parenthesized_comments("2026(comment)-01-05"),
951+
"2026-01-05"
952+
);
953+
assert_eq!(strip_parenthesized_comments("()"), "");
954+
assert_eq!(strip_parenthesized_comments("((foo)2026-01-05)"), "");
955+
956+
// These cases test the balanced parentheses removal feature
957+
// which extends beyond what GNU date strictly supports
958+
assert_eq!(strip_parenthesized_comments("a(b)c"), "ac");
959+
assert_eq!(strip_parenthesized_comments("a(b)c(d)e"), "ace");
960+
assert_eq!(strip_parenthesized_comments("(a)(b)"), "");
961+
962+
// When parentheses are unmatched, processing stops at the unmatched opening paren
963+
// In this case "a(b)c(d", the (b) is balanced but (d is unmatched
964+
// We process "a(b)c" and stop at the unmatched "(d"
965+
assert_eq!(strip_parenthesized_comments("a(b)c(d"), "ac");
966+
967+
// Additional edge cases for nested and complex parentheses
968+
assert_eq!(strip_parenthesized_comments("a(b(c)d)e"), "ae"); // Nested balanced
969+
assert_eq!(strip_parenthesized_comments("a(b(c)d"), "a"); // Nested unbalanced
970+
assert_eq!(strip_parenthesized_comments("a(b)c(d)e(f"), "ace"); // Multiple groups, last unmatched
971+
}
888972
}

tests/by-util/test_date.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,3 +1446,40 @@ fn test_date_locale_fr_french() {
14461446
"Output should include timezone information, got: {stdout}"
14471447
);
14481448
}
1449+
1450+
#[test]
1451+
fn test_date_parenthesis_comment() {
1452+
// GNU compatibility: Text in parentheses is treated as a comment and removed.
1453+
let cases = [
1454+
// (input, format, expected_output)
1455+
("(", "+%H:%M:%S", "00:00:00\n"),
1456+
("1(ignore comment to eol", "+%H:%M:%S", "01:00:00\n"),
1457+
("2026-01-05(this is a comment", "+%Y-%m-%d", "2026-01-05\n"),
1458+
("2026(this is a comment)-01-05", "+%Y-%m-%d", "2026-01-05\n"),
1459+
("((foo)2026-01-05)", "+%H:%M:%S", "00:00:00\n"), // Nested/unbalanced case
1460+
("(2026-01-05(foo))", "+%H:%M:%S", "00:00:00\n"), // Balanced parentheses removed (empty result)
1461+
];
1462+
1463+
for (input, format, expected) in cases {
1464+
new_ucmd!()
1465+
.env("TZ", "UTC")
1466+
.arg("-d")
1467+
.arg(input)
1468+
.arg("-u")
1469+
.arg(format)
1470+
.succeeds()
1471+
.stdout_only(expected);
1472+
}
1473+
}
1474+
1475+
#[test]
1476+
fn test_date_parenthesis_vs_other_special_chars() {
1477+
// Ensure parentheses are special but other chars like [, ., ^ are still rejected
1478+
for special_char in ["[", ".", "^"] {
1479+
new_ucmd!()
1480+
.arg("-d")
1481+
.arg(special_char)
1482+
.fails()
1483+
.stderr_contains("invalid date");
1484+
}
1485+
}

0 commit comments

Comments
 (0)