From 2df625fb02c789cb4fb6907533b9c305493164d7 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 21:39:19 +0330 Subject: [PATCH 1/3] touch: declare SetFileTime's handle as voidptr so it compiles on Windows The Windows CI job has been failing on main, not just on branches: touch/touch_windows.c.v:17:20: error: cannot use oidptr as u32 in argument 1 to C.SetFileTime A HANDLE is a pointer. vlib's C.CreateFileW returns voidptr, and used to return u32, so the u32 in this declaration stopped matching. touch is not in the Windows ignore list, so the whole Windows build stopped with it. One word, no behaviour change: the file did not compile, so there was no behaviour to change. --- src/touch/touch_windows.c.v | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/touch/touch_windows.c.v b/src/touch/touch_windows.c.v index b99e146c..19657b54 100644 --- a/src/touch/touch_windows.c.v +++ b/src/touch/touch_windows.c.v @@ -1,4 +1,7 @@ -fn C.SetFileTime(hfile u32, const_creation_time voidptr, const_access_time voidptr, const_modification_time voidptr) bool +// The handle is a voidptr because that is what vlib's C.CreateFileW returns; +// it used to return u32, and declaring the parameter as u32 is what made this +// file stop compiling on current V. A HANDLE is a pointer either way. +fn C.SetFileTime(hfile voidptr, const_creation_time voidptr, const_access_time voidptr, const_modification_time voidptr) bool fn lutime(path string, acctime int, modtime int) ! { creation_time := t2filetime(-1) From cdbafb51f37da335fe4a0f1d1ee3453548960c45 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 23:21:49 +0330 Subject: [PATCH 2/3] touch: read a date with no zone in it as local time A string with no zone in it names a local time. This read it as UTC, so every such date landed hours off. Measured against GNU on this machine at +0330: touch -d '2020-01-02 03:04:05' GNU 2020-01-02 03:04:05 before 2020-01-02 06:34:05 The offset cannot come from time.offset(), which reports the zone now. The same zone wants a different offset depending on the date, and both of these were measured rather than assumed: TZ=Asia/Tehran 2021-06-07 08:09:10 wants +0430, and +0330 now TZ=America/New_York 2020-01-02 00:00:00 wants -0500, and -0400 now An earlier attempt here subtracted time.offset() and looked correct on every case in the C locale; it was one hour out for June in Tehran, silently. The offset in force at the date comes from zoneinfo's offset_at, which is what this uses, applied twice so that a first guess landing on the far side of a DST transition is corrected. ## The tests could not have caught this Every -d test in touch_test.v computed its expectation with time.parse_iso8601(date).unix() which is the very call the implementation makes. They agreed with the code by construction, so they would have passed with the bug in place and would fail on any machine that is not at UTC. The expectations are now literal stamps read off GNU 9.4 with TZ pinned to Asia/Tehran, a zone whose offset differs by an hour between its two periods, and each test pins and restores TZ so the result does not depend on the host. Breaking the fix again confirms they bite. With local_wall_clock returning the uncorrected value: test_parse_datetime_naive_string_is_local 1577923200 != 1577910600 test_parse_datetime_uses_the_offset_at_that_date 1623053350 != 1623037150 test_touch_create_with_d_option 1669892401 != 1669879801 and test_parse_datetime_leaves_an_explicit_zone_alone still passed, which is the point: the set discriminates rather than being uniformly red. ## Comparing 48 invocations, nine absolute forms and seven relative ones, each under three zones: UTC as a control where a naive read looks correct, Asia/Tehran, and America/New_York for southern hemisphere rules. 13 of 48 before, 27 of 48 now, and the 21 that still differ are exactly the seven relative forms in each zone: yesterday, tomorrow, 2 hours ago, next week, last month, -1 day, +2 weeks Relative forms are a separate piece of work, not a variant of this one. They also embed the current clock, so their comparison has to be the delta from now rather than the stamp. `v fmt -verify .` passes, `v run build.vsh` builds all 76, and `v test .` reports 49 of 49. --- src/touch/datetime.v | 85 ++++++++++++++++++ src/touch/touch.v | 9 +- src/touch/touch_test.v | 197 +++++++++++++++++++++++++++++++++-------- 3 files changed, 252 insertions(+), 39 deletions(-) create mode 100644 src/touch/datetime.v diff --git a/src/touch/datetime.v b/src/touch/datetime.v new file mode 100644 index 00000000..fa8459e6 --- /dev/null +++ b/src/touch/datetime.v @@ -0,0 +1,85 @@ +import time + +// A date string with no zone in it names a *local* time. That is the whole +// point of this file, and it is the one thing the port had wrong: a naive +// string was read as though it were UTC, so on this machine, at +0330, +// +// touch -d '2020-01-02 03:04:05' +// +// set the file to 06:34:05, three and a half hours late, and GNU sets 03:04:05. +// +// The offset cannot come from time.offset(), which reports the zone now. The +// same zone wants a different offset depending on the date, and both of these +// were measured against GNU rather than assumed: +// +// TZ=Asia/Tehran 2021-06-07 08:09:10 wants +0430, and +0330 now +// TZ=America/New_York 2020-01-02 00:00:00 wants -0500, and -0400 now +// +// So the offset has to be the one in force at the date being converted, which +// is what zoneinfo's offset_at answers. +const unresolved = 'no local time zone could be determined' + +// parse_datetime turns the argument of touch -d or -t into an absolute instant. +// A string that carries its own offset is already an instant and is passed +// through; a string without one is read in the local zone. +fn parse_datetime(s string) !i64 { + parsed := time.parse_iso8601(s) or { return error('unable to parse date ${s}') } + if has_explicit_zone(s) { + return parsed.unix() + } + loc := time.load_location('Local') or { return error(unresolved) } + return local_wall_clock(loc, parsed) +} + +// local_wall_clock converts a parsed Time whose calendar fields are local wall +// time into an absolute instant. +// +// parsed.unix() read those fields as if they were UTC, which is the value to +// correct. Subtracting the zone offset at *that* value is one step from the +// answer, and a second step settles it: when the first guess lands on the far +// side of a DST transition the offset it used was the wrong one. Two steps +// agree everywhere except inside the transition hour itself, where GNU also +// has to pick one and there is nothing to check against. +fn local_wall_clock(loc &time.Location, parsed time.Time) i64 { + naive := parsed.unix() + mut guess := naive + for _ in 0 .. 2 { + offset := loc.offset_at(guess) or { break } + next := naive - i64(offset) + if next == guess { + break + } + guess = next + } + return guess +} + +// has_explicit_zone reports whether the string says which zone it is in, which +// is the difference between an instant and a wall clock reading. +// +// The zone can only appear at the end of the time part, and the date has already +// been split off, so a sign inside the time token can only be a zone: times are +// written with ':' and '.', never with '+' or '-'. That makes the length and +// digit checks unnecessary, and getting them wrong is how `+03:30` came to be +// read as unzoned for one round. +fn has_explicit_zone(s string) bool { + mut tp := s.index_('T') + if tp == -1 { + tp = s.index_(' ') + if tp == -1 { + return false + } + } else { + // Step over the T, or the token below still starts with it. + tp++ + } + token := s[tp..].split(' ').last() + if token.len < 2 { + return false + } + if token.contains('+') || token.contains('-') { + return true + } + last := token[token.len - 1] + return last == `Z` || last == `z` +} diff --git a/src/touch/touch.v b/src/touch/touch.v index 92790ab5..27c1598b 100644 --- a/src/touch/touch.v +++ b/src/touch/touch.v @@ -112,10 +112,13 @@ fn get_date_time(args TouchArgs) (int, int) { dt := if args.date_arg.len > 0 { args.date_arg } else { args.time_arg } if dt.len > 0 { - date := time.parse_iso8601(dt) or { - common.exit_with_error_message(app_name, 'unable to parse date ${dt}') + // parse_datetime, not time.parse_iso8601: a string with no zone in it + // names a local time, and the offset that has to be applied is the one + // in force at that date, not the one in force now. See datetime.v. + stamp := parse_datetime(dt) or { + common.exit_with_error_message(app_name, err.msg()) } - return int(date.unix()), int(date.unix()) + return stamp, stamp } if args.reference.len > 0 { diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index c482e5de..1d9bd5c7 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -3,6 +3,22 @@ module main import os import time +// The expectations in this file were read off GNU touch 9.4, not derived from +// the code under test. The previous version computed them with +// time.parse_iso8601(date).unix(), which is the very call the implementation +// makes, so it agreed with the code by construction and could not have caught +// the bug it existed to guard. Every stamp below is a literal measured under +// TZ=Asia/Tehran, a zone whose offset differs by a full hour between summer and +// winter, which is what makes it worth testing on. +// +// 2022-12-01T11:00:01 -> 1669879801 +// 2022-12-01T11:50:02 -> 1669882802 +// 2023-01-01T20:00:35 -> 1672590635 +// +// A zone with no rules available would make these meaningless, so the tests say +// so rather than quietly passing. +const tehran = 'Asia/Tehran' + fn temp_file_name() string { dir := os.temp_dir() file := '${dir}/t${time.ticks()}' @@ -17,6 +33,23 @@ fn pass() { assert true } +// pin_zone sets TZ for the duration of a test and hands back what to restore. +// The zone has to be forced: on a machine whose own zone happens to be UTC the +// whole bug this file guards is invisible. +fn pin_zone(zone string) string { + saved := os.getenv('TZ') + _ = os.setenv('TZ', zone, true) + return saved +} + +fn zone_available() bool { + time.load_location('Local') or { + p('no local zone information on this machine, skipping the ${tehran} assertions') + return false + } + return true +} + fn test_touch_one_file_no_options() { p(@METHOD) file := temp_file_name() @@ -49,62 +82,151 @@ fn test_touch_no_create_option() { pass() } +// A string with no zone in it names a local time. Read as UTC it lands three +// and a half hours late here, which is what these five used to do. +fn test_parse_datetime_naive_string_is_local() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // A date with no time is local midnight, not UTC midnight. + assert parse_datetime('2020-01-02')! == 1_577_910_600 + assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 + assert parse_datetime('2020-01-02T03:04:05')! == 1_577_921_645 + assert parse_datetime('2020-02-29 12:00:00')! == 1_582_965_000 + assert parse_datetime('2025-01-01 00:00:00')! == 1_735_677_000 + pass() +} + +// The offset that applies is the one in force at that date, not the one in force +// now. Tehran is +0430 in June and +0330 in October, so subtracting the current +// offset puts this one an hour out: 08:09:10 - 4:30 = 03:39:10 UTC. +fn test_parse_datetime_uses_the_offset_at_that_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2021-06-07 08:09:10')! == 1_623_037_150 + // And the same zone in its other period, to show both are in play. + assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 + pass() +} + +// A string that says its zone is already an instant and must not be shifted. +fn test_parse_datetime_leaves_an_explicit_zone_alone() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2020-01-02T03:04:05Z')! == 1_577_934_245 + // +03:30 is the zone's own offset in January, so this lands where the naive + // spelling of the same wall clock does. It has to get there on its own. + assert parse_datetime('2020-01-02T03:04:05+03:30')! == 1_577_921_645 + pass() +} + +fn test_has_explicit_zone() { + p(@METHOD) + assert has_explicit_zone('2020-01-02T03:04:05Z') + assert has_explicit_zone('2020-01-02T03:04:05z') + assert has_explicit_zone('2020-01-02T03:04:05+03:30') + assert has_explicit_zone('2020-01-02T03:04:05-05:00') + assert has_explicit_zone('2020-01-02 03:04:05Z') + assert has_explicit_zone('2020-01-02 03:04:05+0330') + + assert !has_explicit_zone('2020-01-02 03:04:05') + assert !has_explicit_zone('2020-01-02T03:04:05') + // The date carries two hyphens and no time part at all. + assert !has_explicit_zone('2020-01-02') + assert !has_explicit_zone('2020-01-02 03:04:05.678') + pass() +} + fn test_touch_create_with_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-d', date, file]) + touch(['touch', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime == unix - assert stat.mtime == unix + assert stat.atime == 1_669_879_801 + assert stat.mtime == 1_669_879_801 os.rm(file)! pass() } fn test_touch_create_with_a_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-a', '-d', date, file]) + touch(['touch', '-a', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime == unix - assert stat.mtime != unix + assert stat.atime == 1_669_879_801 + assert stat.mtime != 1_669_879_801 os.rm(file)! pass() } fn test_touch_create_with_m_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-m', '-d', date, file]) + touch(['touch', '-m', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime != unix - assert stat.mtime == unix + assert stat.atime != 1_669_879_801 + assert stat.mtime == 1_669_879_801 os.rm(file)! pass() } fn test_touch_with_reference_file() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } rfile := temp_file_name() - mdate := '2022-12-01T11:00:01' - mtime := time.parse_iso8601(mdate)!.unix() - touch(['touch', '-d', mdate, rfile]) - - adate := '2022-12-01T11:50:02' - atime := time.parse_iso8601(adate)!.unix() - touch(['touch', '-a', '-d', adate, rfile]) + touch(['touch', '-d', '2022-12-01T11:00:01', rfile]) + touch(['touch', '-a', '-d', '2022-12-01T11:50:02', rfile]) file := rfile + 'x' touch(['touch', '-r', rfile, file]) stat := os.lstat(file)! - assert stat.atime == atime - assert stat.mtime == mtime + assert stat.atime == 1_669_882_802 + assert stat.mtime == 1_669_879_801 os.rm(file)! os.rm(rfile)! @@ -113,15 +235,20 @@ fn test_touch_with_reference_file() { fn test_touch_no_reference_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - fdate := '2022-12-01T11:00:01' - ftime := time.parse_iso8601(fdate)!.unix() - touch(['touch', '-d', fdate, file]) + touch(['touch', '-d', '2022-12-01T11:00:01', file]) // confirm correct start state stat := os.lstat(file)! - assert stat.atime == ftime - assert stat.mtime == ftime + assert stat.atime == 1_669_879_801 + assert stat.mtime == 1_669_879_801 if os.user_os() == 'windows' { eprintln('skip symlink checks on windows, they need administrative permissions') @@ -132,19 +259,17 @@ fn test_touch_no_reference_option() { os.symlink(file, link)! // touch the symlink - ldate := '2023-01-01T20:00:35' - ltime := time.parse_iso8601(ldate)!.unix() - touch(['touch', '-h', '-d', ldate, link]) + touch(['touch', '-h', '-d', '2023-01-01T20:00:35', link]) // check original file fstat := os.lstat(file)! - assert fstat.atime == ftime - assert fstat.mtime == ftime + assert fstat.atime == 1_669_879_801 + assert fstat.mtime == 1_669_879_801 // lstat does not 'follow' links lstat := os.lstat(link)! - assert lstat.atime == ltime - assert lstat.mtime == ltime + assert lstat.atime == 1_672_590_635 + assert lstat.mtime == 1_672_590_635 os.rm(link)! os.rm(file)! From 357ff9950aa4fd5e235cee8a8c0212678c7ae425 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 23:55:58 +0330 Subject: [PATCH 3/3] touch: accept the date forms GNU takes that parse_iso8601 does not Eight spellings were rejected outright, all of them ordinary: -d @1600000000 -t 202001020304 -d 03:04 -d 03:04:05 -t 2001020304 -d 5 -d 10:00 GMT+3 -d 20200102 Sixteen cases per time zone, three zones: 0 of 48 before, 45 of 48 now. Each rule came from GNU rather than from the manual, and two of them are not what the documentation's shape suggests: - `-t` reads [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so the seconds only exist as the fractional part, and a ten digit reading takes the century from the first two, giving 2020 rather than 1920. - The compact reading belongs to `-t`. Under `-d`, GNU reads the same twelve digits as something else entirely and lands in the year 2446, so `-d` must not quietly accept them as a date. parse_datetime therefore takes which flag it came from. - A bare time means today, not 1970, so the date has to come from the local zone. A bare YYYYMMDD is local midnight on that date. - A zone word after a bare time names today at that time in that zone, so the suffix's sign is the opposite of how it reads: `10:00 GMT+3` lands three hours *before* `10:00 UTC`. - `@1600000000.5` drops the fraction, since a file stamp holds whole seconds. Everything recognised is rewritten into an ISO 8601 string first, so the zone logic from the previous commit stays in one place instead of being repeated once per spelling. ## The one that is left `-d 20200102T0304` gives 2020-01-02 10:04 at GNU, under every zone, and the result does not correspond to any reading of 0304 that can be found. It is not implemented rather than matched bug for bug, and this is the whole of the remaining 3 of 48. ## Tests Six new tests, alongside the existing ones, with expectations read off GNU under TZ=Asia/Tehran. Two of them cannot be a fixed number: "today" moves, so the bare-time test reads the result back into the zone and compares the calendar fields, and the compact-stamp test compares against the spelled out form rather than a literal. Breaking the normalisation out of parse_datetime makes the suite fail at test_parse_datetime_epoch, and the matrix goes back to rejecting all sixteen spellings. `v fmt -verify .` passes, `v run build.vsh` builds all 76, `v test .` reports 49 of 49, and src/touch builds with no notices. Stacked on #204, which this needs for the local-time reading that bare times depend on. --- src/touch/datetime.v | 268 ++++++++++++++++++++++++++++++++++++++++- src/touch/touch.v | 2 +- src/touch/touch_test.v | 134 +++++++++++++++++++-- 3 files changed, 391 insertions(+), 13 deletions(-) diff --git a/src/touch/datetime.v b/src/touch/datetime.v index fa8459e6..b152bb6c 100644 --- a/src/touch/datetime.v +++ b/src/touch/datetime.v @@ -1,3 +1,4 @@ +import strconv import time // A date string with no zone in it names a *local* time. That is the whole @@ -20,9 +21,270 @@ import time const unresolved = 'no local time zone could be determined' // parse_datetime turns the argument of touch -d or -t into an absolute instant. -// A string that carries its own offset is already an instant and is passed -// through; a string without one is read in the local zone. -fn parse_datetime(s string) !i64 { +// compact says which flag it came from, because the compact stamp belongs to -t: +// GNU reads `-t 202001020304` as a date and `-d 202001020304` as something else +// entirely, landing in the year 2446. +// +// Everything this file recognises is rewritten into an ISO 8601 string first, +// so that one conversion at the end handles all of it. That keeps the zone logic +// in a single place instead of once per spelling. +fn parse_datetime(s string, compact bool) !i64 { + if s.starts_with('@') { + return epoch_seconds(s) + } + iso := to_iso8601(s, compact) + if iso != none { + return from_iso8601(iso) + } + return from_iso8601(s) +} + +// epoch_seconds reads @1600000000 and @1600000000.5. GNU keeps only the whole +// second, since that is all a file stamp can hold. +fn epoch_seconds(s string) !i64 { + body := s[1..] + mut whole := body + if idx := body.index('.') { + whole = body[..idx] + } + if whole.len == 0 { + return error('unable to parse date ${s}') + } + n := strconv.atoi64(whole) or { return error('unable to parse date ${s}') } + return n +} + +// to_iso8601 rewrites the spellings GNU accepts but parse_iso8601 does not, and +// returns none for anything it does not recognise so the caller can fall back. +fn to_iso8601(s string, compact bool) ?string { + if compact { + compact_iso := compact_stamp(s) + if compact_iso != none { + return compact_iso + } + } + zoned := split_zone_word(s) + if zoned != none { + // `10:00 UTC` and `10:00 GMT+3` name today at that time in that zone. + // Today is needed because a bare time means today at GNU, not 1970. + zoned_clock := clock_of(zoned.rest) + if zoned_clock == none { + return none + } + y, m, d := local_today() + stamp := '${y}-${pad2(m)}-${pad2(d)}' + + 'T${pad2(zoned_clock.hour)}:${pad2(zoned_clock.minute)}:${pad2(zoned_clock.second)}' + + zone_suffix(zoned.offset) + return stamp + } + basic := basic_date(s) + if basic != none { + // A bare YYYYMMDD is local midnight on that date. + return '${basic.year}-${pad2(basic.month)}-${pad2(basic.day)}T00:00:00' + } + bare := clock_of(s) + if bare != none { + // A bare time is today at that time. + y, m, d := local_today() + return '${y}-${pad2(m)}-${pad2(d)}' + + 'T${pad2(bare.hour)}:${pad2(bare.minute)}:${pad2(bare.second)}' + } + return none +} + +struct Clock { + hour int + minute int + second int +} + +// compact_stamp reads -t's [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so +// the seconds only exist as the fractional part: +// +// 202001020304 -> 2020-01-02 03:04 +// 2001020304 -> 2020-01-02 03:04, the century supplied from the first two +// 20200102030405 -> invalid date format at GNU +fn compact_stamp(s string) ?string { + mut body := s + mut second := 0 + if idx := s.index('.') { + body = s[..idx] + frac := s[idx + 1..] + if frac.len != 2 || !all_digits(frac) { + return none + } + second = strconv.atoi(frac) or { return none } + } + if (body.len != 10 && body.len != 12) || !all_digits(body) { + return none + } + year := if body.len == 12 { + strconv.atoi(body[..4]) or { return none } + } else { + // Two digit year: GNU reads 20 as 2020, so the century is the one the + // number is nearest to, which for every year since 1970 is 2000 +. + 2000 + (strconv.atoi(body[..2]) or { return none }) + } + rest := body[body.len - 8..] + month := strconv.atoi(rest[..2]) or { return none } + day := strconv.atoi(rest[2..4]) or { return none } + hour := strconv.atoi(rest[4..6]) or { return none } + minute := strconv.atoi(rest[6..8]) or { return none } + if month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 + || second > 60 { + return none + } + return '${year}-${pad2(month)}-${pad2(day)}T${pad2(hour)}:${pad2(minute)}:${pad2(second)}' +} + +// split_zone_word pulls a trailing UTC or GMT, with an optional signed hour +// count, off the end of a bare time. The offset is seconds east of UTC, which is +// the opposite sign to the way the suffix is written. +struct Zoned { + offset int + rest string +} + +fn split_zone_word(s string) ?Zoned { + words := s.split(' ') + if words.len < 2 { + return none + } + last := words[words.len - 1] + mut base := '' + for prefix in ['GMT', 'UTC'] { + if last.starts_with(prefix) { + base = prefix + break + } + } + if base == '' { + return none + } + mut hours := 0 + zone_rest := last[base.len..] + if zone_rest.len > 0 { + if zone_rest[0] != `+` && zone_rest[0] != `-` { + return none + } + n := strconv.atoi(zone_rest[1..]) or { return none } + hours = if zone_rest[0] == `-` { -n } else { n } + } + // Drop the word and the space in front of it. The parentheses matter: V + // parses `s[:s.len - last.len]` as `s[:s.len]` followed by arithmetic. + cut := s.len - last.len - 1 + return Zoned{ + offset: hours * 3600 + rest: s[..cut] + } +} + +fn zone_suffix(offset int) string { + if offset == 0 { + return 'Z' + } + sign := if offset < 0 { '-' } else { '+' } + magnitude := if offset < 0 { -offset } else { offset } + return '${sign}${pad2(magnitude / 3600)}:${pad2((magnitude % 3600) / 60)}' +} + +// clock_of reads a bare time of day: 5 is 05:00, 0304 is 03:04, and 03:04 and +// 03:04:05 are themselves. Returns none for anything longer, which keeps a +// twelve digit compact stamp out of this path. +fn clock_of(s string) ?Clock { + if s.contains(':') { + parts := s.split(':') + if parts.len < 2 || parts.len > 3 { + return none + } + hour := strconv.atoi(parts[0]) or { return none } + minute := strconv.atoi(parts[1]) or { return none } + mut second := 0 + if parts.len == 3 { + second = strconv.atoi(parts[2]) or { return none } + } + if hour > 23 || minute > 59 || second > 60 { + return none + } + return Clock{ + hour: hour + minute: minute + second: second + } + } + if all_digits(s) && (s.len == 1 || s.len == 2) { + hour := strconv.atoi(s) or { return none } + if hour > 23 { + return none + } + return Clock{ + hour: hour + minute: 0 + second: 0 + } + } + if s.len == 4 && all_digits(s) { + hour := strconv.atoi(s[..2]) or { return none } + minute := strconv.atoi(s[2..]) or { return none } + if hour > 23 || minute > 59 { + return none + } + return Clock{ + hour: hour + minute: minute + second: 0 + } + } + return none +} + +// BasicDate is YYYYMMDD, which parse_iso8601 rejects because it expects the +// dashes. +struct BasicDate { + year int + month int + day int +} + +fn basic_date(s string) ?BasicDate { + if s.len != 8 || !all_digits(s) { + return none + } + date := BasicDate{ + year: strconv.atoi(s[..4]) or { return none } + month: strconv.atoi(s[4..6]) or { return none } + day: strconv.atoi(s[6..]) or { return none } + } + if date.month < 1 || date.month > 12 || date.day < 1 || date.day > 31 { + return none + } + return date +} + +fn local_today() (int, int, int) { + now := time.unix(time.now().unix()).local() + return now.year, now.month, now.day +} + +fn all_digits(s string) bool { + if s.len == 0 { + return false + } + for ch in s { + if ch < `0` || ch > `9` { + return false + } + } + return true +} + +fn pad2(n int) string { + return if n < 10 { '0${n}' } else { n.str() } +} + +// from_iso8601 resolves an ISO string, converting a zone-less one from local +// wall clock to an instant. +fn from_iso8601(s string) !i64 { parsed := time.parse_iso8601(s) or { return error('unable to parse date ${s}') } if has_explicit_zone(s) { return parsed.unix() diff --git a/src/touch/touch.v b/src/touch/touch.v index 27c1598b..01fffb62 100644 --- a/src/touch/touch.v +++ b/src/touch/touch.v @@ -115,7 +115,7 @@ fn get_date_time(args TouchArgs) (int, int) { // parse_datetime, not time.parse_iso8601: a string with no zone in it // names a local time, and the offset that has to be applied is the one // in force at that date, not the one in force now. See datetime.v. - stamp := parse_datetime(dt) or { + stamp := parse_datetime(dt, args.time_arg.len > 0) or { common.exit_with_error_message(app_name, err.msg()) } return stamp, stamp diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index 1d9bd5c7..eb289db6 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -94,11 +94,11 @@ fn test_parse_datetime_naive_string_is_local() { return } // A date with no time is local midnight, not UTC midnight. - assert parse_datetime('2020-01-02')! == 1_577_910_600 - assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 - assert parse_datetime('2020-01-02T03:04:05')! == 1_577_921_645 - assert parse_datetime('2020-02-29 12:00:00')! == 1_582_965_000 - assert parse_datetime('2025-01-01 00:00:00')! == 1_735_677_000 + assert parse_datetime('2020-01-02', false)! == 1_577_910_600 + assert parse_datetime('2020-01-02 03:04:05', false)! == 1_577_921_645 + assert parse_datetime('2020-01-02T03:04:05', false)! == 1_577_921_645 + assert parse_datetime('2020-02-29 12:00:00', false)! == 1_582_965_000 + assert parse_datetime('2025-01-01 00:00:00', false)! == 1_735_677_000 pass() } @@ -114,9 +114,9 @@ fn test_parse_datetime_uses_the_offset_at_that_date() { if !zone_available() { return } - assert parse_datetime('2021-06-07 08:09:10')! == 1_623_037_150 + assert parse_datetime('2021-06-07 08:09:10', false)! == 1_623_037_150 // And the same zone in its other period, to show both are in play. - assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 + assert parse_datetime('2020-01-02 03:04:05', false)! == 1_577_921_645 pass() } @@ -130,10 +130,10 @@ fn test_parse_datetime_leaves_an_explicit_zone_alone() { if !zone_available() { return } - assert parse_datetime('2020-01-02T03:04:05Z')! == 1_577_934_245 + assert parse_datetime('2020-01-02T03:04:05Z', false)! == 1_577_934_245 // +03:30 is the zone's own offset in January, so this lands where the naive // spelling of the same wall clock does. It has to get there on its own. - assert parse_datetime('2020-01-02T03:04:05+03:30')! == 1_577_921_645 + assert parse_datetime('2020-01-02T03:04:05+03:30', false)! == 1_577_921_645 pass() } @@ -154,6 +154,122 @@ fn test_has_explicit_zone() { pass() } +// @1600000000 is an instant already, so no zone logic applies and the fraction +// is dropped, since a file stamp holds whole seconds. Read off GNU: +// @1600000000 -> 1600000000 @0 -> 0 @1600000000.5 -> 1600000000 +fn test_parse_datetime_epoch() { + p(@METHOD) + assert parse_datetime('@1600000000', false)! == 1_600_000_000 + assert parse_datetime('@0', false)! == 0 + assert parse_datetime('@1600000000.5', false)! == 1_600_000_000 + pass() +} + +// -t's compact stamp is [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so +// the seconds only exist as the fractional part, and a ten digit reading takes +// the century from the first two. All four were read off GNU under Tehran. +fn test_parse_datetime_compact_stamp() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // The compact reading is the same wall clock as the spelled out one, and the + // two digit year resolves to 2020 rather than 1920. + spelled := parse_datetime('2020-01-02 03:04:00', false)! + assert parse_datetime('202001020304', true)! == spelled + assert parse_datetime('2001020304', true)! == spelled + // Fourteen digits is not a stamp. + assert_parse_fails('20200102030405', true) + // The compact reading belongs to -t only. Under -d GNU reads the same digits + // as something else entirely and lands in the year 2446, so -d must not + // quietly accept them as a date. + assert_parse_fails('202001020304', false) + pass() +} + +// A bare time means today at that time, not 1970. The date therefore has to come +// from the local zone, which is what local_today reads. The expectations are +// built from the same local date rather than hard coded, because "today" moves. +fn test_parse_datetime_bare_time_is_today() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + loc := time.load_location('Local') or { return } + today := time.unix(time.now().unix()).in(loc) or { return } + midnight := parse_datetime('00:00', false)! + day := parse_datetime('00:00:00', false)! + assert midnight == day + // 5 is 05:00, and 0304 is 03:04. + assert parse_datetime('5', false)! == midnight + 5 * 3600 + assert parse_datetime('0304', false)! == midnight + (3 * 3600 + 4 * 60) + assert parse_datetime('03:04', false)! == parse_datetime('0304', false)! + assert parse_datetime('03:04:05', false)! == parse_datetime('0304', false)! + 5 + // And it is today's date, not a fixed one: read the result back into the zone + // and compare the calendar fields with today. + read_back := time.unix(midnight).in(loc) or { return } + assert read_back.year == today.year + assert read_back.month == today.month + assert read_back.day == today.day + assert read_back.hour == 0 + pass() +} + +// A bare YYYYMMDD is local midnight on that date. Measured under Tehran: +// 20200102 -> 1577910600 +fn test_parse_datetime_basic_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('20200102', false)! == 1_577_910_600 + assert parse_datetime('20200102', false)! == parse_datetime('2020-01-02', false)! + pass() +} + +// A zone word after a bare time names today at that time in that zone, so the +// suffix's sign is the opposite of what it looks like. Measured under Tehran, +// where the local offset is +0330: +// 10:00 UTC -> 2026-10-03 13:30 local +// 10:00 GMT+3 -> 2026-10-03 10:30 local +// 10:00 UTC-2 -> 2026-10-03 15:30 local +fn test_parse_datetime_zone_word() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + plain := parse_datetime('10:00', false)! + utc := parse_datetime('10:00 UTC', false)! + assert utc - plain == 3 * 3600 + 30 * 60 + assert parse_datetime('10:00 GMT', false)! == utc + // GMT+3 is three hours ahead of UTC, so it lands three hours earlier than UTC. + assert utc - parse_datetime('10:00 GMT+3', false)! == 3 * 3600 + // UTC-2 is two hours behind UTC. + assert parse_datetime('10:00 UTC-2', false)! - utc == 2 * 3600 + pass() +} + +fn assert_parse_fails(s string, compact bool) { + parse_datetime(s, compact) or { return } + assert false, 'expected ${s} to be rejected' +} + fn test_touch_create_with_d_option() { p(@METHOD) saved := pin_zone(tehran)