diff --git a/src/touch/datetime.v b/src/touch/datetime.v new file mode 100644 index 00000000..56efbbcc --- /dev/null +++ b/src/touch/datetime.v @@ -0,0 +1,600 @@ +import os +import strconv +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. +// 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) + } + // A relative item belongs to -d alone. -t reads the compact stamp and nothing + // else, so `touch -t yesterday` is an error at GNU and has to be one here. + if !compact { + if relative := relative_date(s) { + return relative + } + } + 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)}' + } + // Month names, weekday names and the C locale's m/d/y come after the shapes + // above, so nothing here can shadow them. + shortened := short_time(s) + if shortened != none { + return shortened + } + worded := worded_date(s) + if worded != none { + return worded + } + return none +} + +// short_time supplies the seconds that `2020-01-02 03:04` and `2020-01-02 03` +// leave out. GNU takes both; parse_iso8601 insists on HH:MM:SS. +fn short_time(s string) ?string { + mut sep := s.index_('T') + mut cut := sep + 1 + if sep == -1 { + sep = s.index_(' ') + if sep == -1 { + return none + } + cut = sep + 1 + } + date_part := s[..sep] + // Only an ISO date, so that `1 March 2020` cannot reach here. + if date_part.len != 10 || !date_part.contains('-') { + return none + } + time_part := s[cut..] + if time_part.count(':') == 2 { + return none + } + clock := clock_of(time_part) or { return none } + return '${date_part}T${pad2(clock.hour)}:${pad2(clock.minute)}:${pad2(clock.second)}' +} + +// month_numbers maps a month name to its number, ignoring case and an optional +// full stop: GNU takes MARCH, march and Mar. alike. +const month_numbers = { + 'january': 1 + 'jan': 1 + 'february': 2 + 'feb': 2 + 'march': 3 + 'mar': 3 + 'april': 4 + 'apr': 4 + 'may': 5 + 'june': 6 + 'jun': 6 + 'july': 7 + 'jul': 7 + 'august': 8 + 'aug': 8 + 'september': 9 + 'sep': 9 + 'sept': 9 + 'october': 10 + 'oct': 10 + 'november': 11 + 'nov': 11 + 'december': 12 + 'dec': 12 +} + +// weekday_names are accepted and then ignored, the way GNU does: `Mon March 2 +// 2020` gives 2020-03-02 whether or not that day was a Monday. +const weekday_names = ['sunday', 'sun', 'monday', 'mon', 'tuesday', 'tue', 'tues', 'wednesday', + 'wed', 'thursday', 'thu', 'thur', 'thurs', 'friday', 'fri', 'saturday', 'sat'] + +// worded_date rewrites the dates that name a month or separate their parts with +// slashes, and returns none for everything else. +// +// What GNU does, all measured: +// March 1, 2020 / 1 March 2020 / Mar 1 2020 the month may come first or last, +// with or without commas, in any +// case +// Mon March 2 2020 a weekday name is ignored +// 1 March a missing year is the current one +// 31 April 2020 rejected, so the day is checked +// March 2020 rejected, so a day is required +// 1/2/2020 and 2/1/2020 the C locale is month first: +// 2 January and 1 February +// 13/2/2020 rejected, so a slash date's first +// number must be a month and is never +// read as a day +// 1/2/20 a two digit year +// March 1 2020 03:04 a time may follow +fn worded_date(s string) ?string { + mut words := s.replace(',', ' ').split(' ').filter(it != '') + if words.len == 0 { + return none + } + // A time may trail the date. It has to carry its colons here: a bare four digit + // token is read as a year inside a worded date, and only as HHMM when it stands + // on its own. GNU draws that line too, since `-d 2020` is 20:20 today while + // `March 1 2020 0304` keeps 2020 as the year and takes 0304 as the time. + mut stamp := '00:00:00' + if words[words.len - 1].contains(':') { + trailing := clock_of(words[words.len - 1]) + if trailing == none { + return none + } + stamp = '${pad2(trailing.hour)}:${pad2(trailing.minute)}:${pad2(trailing.second)}' + words = words[..words.len - 1] + if words.len == 0 { + // A bare time after all, which to_iso8601 already handles. + return none + } + } + // A slashed date is one token and follows the C locale strictly. + if words.len == 1 { + if iso := slashed_date(words[0], stamp) { + return iso + } + } + mut month := 0 + mut day := 0 + mut year := 0 + for word in words { + mut w := word.to_lower_ascii() + if w.contains('.') { + w = w.trim_right('.').to_lower_ascii() + } + if m := month_numbers[w] { + month = m + continue + } + if weekday_names.contains(w) { + continue + } + n := strconv.atoi(w) or { return none } + // Four digits are ambiguous on their own: alone they are HHMM, but after + // a year has been taken they are another time, as + // `March 1 2020 0304` is 03:04 on 1 March 2020. + if word.len == 4 && all_digits(w) { + hour := strconv.atoi(w[..2]) or { return none } + minute := strconv.atoi(w[2..]) or { return none } + if year != 0 && hour <= 23 && minute <= 59 { + stamp = '${pad2(hour)}:${pad2(minute)}:00' + continue + } + if year == 0 { + year = n + continue + } + return none + } + // A number in day range is the day. Anything else is the year, and a two + // digit year takes its century the same way the slashed form does. + if day == 0 && n >= 1 && n <= 31 { + day = n + continue + } + if year == 0 { + year = if word.len == 2 { 2000 + n } else { n } + continue + } + return none + } + if month == 0 || day == 0 { + return none + } + if year == 0 { + // No year given, so this year. Measured: `1 March` on 2026-10-03 gives + // 2026-03-01. + year = time.now().year + } + if day > days_in_month(month, year) { + // GNU rejects 31 April rather than rolling it over into May. + return none + } + return '${year}-${pad2(month)}-${pad2(day)}T${stamp}' +} + +// slashed_date reads the C locale's m/d/y, where the first number must be a +// month: 13/2/2020 is an error at GNU, not 13 February. +fn slashed_date(word string, stamp string) ?string { + parts := word.split('/') + if parts.len != 3 { + return none + } + month := strconv.atoi(parts[0]) or { return none } + day := strconv.atoi(parts[1]) or { return none } + if month < 1 || month > 12 || day < 1 { + return none + } + mut year := 0 + if parts[2].len == 4 { + year = strconv.atoi(parts[2]) or { return none } + } else if parts[2].len == 2 { + // Measured: 1/2/20 is 2020-01-02, so the century comes from the first two. + year = 2000 + (strconv.atoi(parts[2]) or { return none }) + } else { + return none + } + if day > days_in_month(month, year) { + return none + } + return '${year}-${pad2(month)}-${pad2(day)}T${stamp}' +} + +// days_in_month is a local copy rather than a reach into time, which keeps this +// file's idea of a calendar in one place. +fn days_in_month(month int, year int) int { + if month == 2 { + leap := (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 + return if leap { 29 } else { 28 } + } + if month == 4 || month == 6 || month == 9 || month == 11 { + return 30 + } + return 31 +} + +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 +} + +// local_today is today's date as the local zone reads it: the epoch moved by the +// offset in force now and then read as UTC fields. Time.local() cannot be used +// for this, because it answers with the machine's zone rather than with TZ. +fn local_today() (int, int, int) { + now := time.now().unix() + mut local := now + if loc := local_location() { + if offset := loc.offset_at(now) { + local += i64(offset) + } + } + today := time.unix(local) + return today.year, today.month, today.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() + } + loc := local_location()! + return local_wall_clock(loc, parsed) +} + +// local_location is the zone a string with no zone in it should be read in. +// +// TZ is consulted before 'Local', which is what a caller who exports TZ means, +// and it has to be consulted at all: on Windows V answers 'Local' with the zone +// the machine is set to whatever the environment says. Measured on this host, +// TZ=America/New_York still gave +0330, which would leave this package's tests +// passing or failing according to where the machine happens to be. +// +// A TZ that names no zone V can load is ignored rather than fatal, so an unusual +// value falls back to the machine's own zone instead of turning every date into +// an error. +fn local_location() !&time.Location { + tz := os.getenv('TZ') + if tz != '' { + if loc := time.load_location(tz) { + return loc + } + } + return time.load_location('Local') or { error(unresolved) } +} + +// 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/relative.v b/src/touch/relative.v new file mode 100644 index 00000000..568c6037 --- /dev/null +++ b/src/touch/relative.v @@ -0,0 +1,287 @@ +module main + +import strconv +import time + +// The relative items GNU resolves against the clock: yesterday, 2 hours ago, +// next week, -1 day, +2 weeks. Every rule here was measured against GNU 9.4 +// rather than read out of its manual, and two of them are the opposite of what +// the shape of the input suggests: +// +// - A bare item is a plain number of seconds, not a shift of the wall clock. +// Under TZ=America/New_York at 18:00:18 EDT, `+1 month` moved by exactly +// 2678400 and landed at 17:00:00 EST; adding a month to local time would have +// made it 18:00 EST, an hour later. Every day, week and month item read the +// same in UTC, Asia/Tehran and America/New_York, which is only possible if +// the zone is not consulted at all. +// +// - `ago` negates the item it follows and leaves the rest alone, so +// `1 day 2 hours ago` is +22 hours and `2 hours 1 day ago` is -22. Negating +// the whole string would give -26 for the first, which is not what GNU prints. +// +// A signed count carries its sign and nothing else: `-1 day` is -86400. Given an +// explicit base date GNU's signed items also shift by an hour per count, so +// `-2 weeks` after a date is +7 days +2 hours; that is a different path in GNU's +// parser and this file deliberately does not implement it. + +// rel_units are the spellings GNU takes, and the two it does not: `1 y`, `1 yr` +// and `1 M` are all invalid, while `1 mon` parses as a weekday name and is +// ignored, which worded_date already handles. +const rel_units = { + 'sec': RelUnit.second + 'secs': RelUnit.second + 'second': RelUnit.second + 'seconds': RelUnit.second + 'min': RelUnit.minute + 'mins': RelUnit.minute + 'minute': RelUnit.minute + 'minutes': RelUnit.minute + 'hour': RelUnit.hour + 'hours': RelUnit.hour + 'day': RelUnit.day + 'days': RelUnit.day + 'week': RelUnit.week + 'weeks': RelUnit.week + 'fortnight': RelUnit.fortnight + 'fortnights': RelUnit.fortnight + 'month': RelUnit.month + 'months': RelUnit.month + 'year': RelUnit.year + 'years': RelUnit.year +} + +enum RelUnit { + second + minute + hour + day + week + fortnight + month + year +} + +// A RelItem is one item's contribution, kept in three units so that the months +// can be applied to the calendar before the rest is added as seconds. That order +// is the difference between 31 January +1 month being 2 March and being 1 March. +struct RelItem { + months int + days int + seconds i64 +} + +fn (item RelItem) negated() RelItem { + return RelItem{ + months: -item.months + days: -item.days + seconds: -item.seconds + } +} + +// relative_date answers with an absolute instant, or none when the string is not a +// relative item. Only the bare forms are here: `2020-01-02 tomorrow` is a GNU +// spelling this does not read, and no test claims otherwise. +fn relative_date(s string) ?i64 { + words := s.split_any(' \t').filter(it != '') + if words.len == 0 { + return none + } + mut items := []RelItem{} + mut i := 0 + for i < words.len { + word := words[i].to_lower_ascii() + // `previous` reads like `last` and is refused outright: GNU takes `last day` + // and rejects `previous day`. + if word == 'previous' { + return none + } + match word { + // `today` and `now` both name this instant. Measured with the clock at + // 01:31:18, `date -d today` returned that second and not midnight, which is + // the opposite of what a date word suggests. + 'now', 'today' { + items << RelItem{} + i++ + } + 'yesterday' { + items << RelItem{ + days: -1 + } + i++ + } + 'tomorrow' { + items << RelItem{ + days: 1 + } + i++ + } + 'ago', 'hence' { + if items.len == 0 { + return none + } + // `hence` is measured to be a no-op, so only `ago` changes anything. + if word == 'ago' { + items[items.len - 1] = items[items.len - 1].negated() + } + i++ + } + 'last', 'next', 'this' { + // A count may not follow the direction word: `last 2 days` and + // `next 2 weeks` are both invalid at GNU. + if i + 1 >= words.len { + return none + } + unit := rel_unit(words[i + 1]) or { return none } + count := if word == 'last' { + -1 + } else if word == 'next' { + 1 + } else { + 0 + } + items << scaled(unit, count) + i += 2 + } + else { + mut sign := 1 + mut digits := word + if digits.starts_with('+') { + digits = digits[1..] + } else if digits.starts_with('-') { + sign = -1 + digits = digits[1..] + } + // A bare number is an hour of the day, which clock_of already reads, + // so it is not a count here, and a count with no unit is incomplete. + if !all_digits(digits) || i + 1 >= words.len { + return none + } + count := strconv.atoi(digits) or { return none } + unit := rel_unit(words[i + 1]) or { return none } + items << scaled(unit, sign * count) + i += 2 + } + } + } + mut months := 0 + mut days := 0 + mut seconds := i64(0) + for item in items { + months += item.months + days += item.days + seconds += item.seconds + } + return shifted(time.now().unix(), months, days, seconds) +} + +// rel_unit looks a word up with `in` first on purpose. A map read of a key that is +// not there yields the zero value without running an `or` block, and the zero +// value of RelUnit is `.second`: that turned every unrecognised word into one +// second, so `-d '1 March'` parsed as a second from now instead of failing. +fn rel_unit(word string) ?RelUnit { + key := word.to_lower_ascii() + if !(key in rel_units) { + return none + } + return rel_units[key] +} + +fn scaled(unit RelUnit, count int) RelItem { + mut months := 0 + mut days := 0 + mut seconds := i64(0) + match unit { + .second { + seconds = i64(count) + } + .minute { + seconds = i64(count) * 60 + } + .hour { + seconds = i64(count) * 3600 + } + .day { + days = count + } + .week { + days = count * 7 + } + .fortnight { + days = count * 14 + } + .month { + months = count + } + .year { + months = count * 12 + } + } + return RelItem{ + months: months + days: days + seconds: seconds + } +} + +// shifted moves an instant by whole months, then whole days, then seconds. +// +// Everything after the months is plain arithmetic on the epoch, which is what the +// measurements call for: a bare `-1 day` is -86400 even in a zone that changes its +// offset that night, so there is nothing here for the time zone to say. +fn shifted(epoch i64, months int, days int, seconds i64) i64 { + utc := time.unix(epoch) + year, month, day := add_months(utc.year, utc.month, utc.day, months) + return days_from_civil(year, month, day) * 86400 + i64(utc.hour) * 3600 + + i64(utc.minute) * 60 + i64(utc.second) + i64(days) * 86400 + seconds +} + +// add_months moves a date by whole months and carries an over-long day into the +// next month rather than clamping it to the last day of the one before. The +// table, from GNU 9.4: +// +// 2020-01-31 +1 -> 2020-03-02 31 February is 2 March +// 2020-03-31 -1 -> 2020-03-02 and 31 February again, going back +// 2020-03-31 +1 -> 2020-05-01 31 April is 1 May +// 2020-02-29 +12 -> 2021-03-01 29 February 2021 is 1 March +fn add_months(year int, month int, day int, count int) (int, int, int) { + total := year * 12 + (month - 1) + count + // Floor division, so a date before 1970 keeps its month instead of wrapping. + // V's / truncates towards zero, which is why the correction is spelled out. + mut y := total / 12 + mut m := total % 12 + 1 + if total < 0 && total % 12 != 0 { + y -= 1 + m += 12 + } + mut d := day + for { + month_len := days_in_month(m, y) + if d <= month_len { + break + } + d -= month_len + m++ + if m > 12 { + m = 1 + y++ + } + } + return y, m, d +} + +// days_from_civil counts days from 1970-01-01 in the proleptic Gregorian calendar. +// It is here so that a bare relative item needs no Location: the answer is a +// number of seconds, and a time zone has no part in it. +fn days_from_civil(year int, month int, day int) int { + mut y := year + if month <= 2 { + y -= 1 + } + era := (if y >= 0 { y } else { y - 399 }) / 400 + year_of_era := y - era * 400 // [0, 399] + shifted_month := (month + 9) % 12 // March is 0 + day_of_year := (153 * shifted_month + 2) / 5 + day - 1 // [0, 365] + day_of_era := year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + + day_of_year // [0, 146096] + return era * 146097 + day_of_era - 719468 +} diff --git a/src/touch/touch.v b/src/touch/touch.v index 92790ab5..01fffb62 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, args.time_arg.len > 0) 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..8b4e23aa 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,589 @@ 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', 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() +} + +// A string with no zone in it is read in the zone TZ names, and this is what makes +// the rest of the file mean anything. On Windows V answers 'Local' with the zone +// the machine is set to whatever the environment says, so measured on this host at +// +0330, TZ=America/New_York still gave +0330 and every stamp below would have been +// off by the difference on any other machine. +// +// 2020-01-02 in UTC -> 1_577_923_200 +// 2020-01-02 in Asia/Tehran -> 1_577_910_600 +// 2020-01-02 in America/New_York -> 1_577_941_200 +fn test_parse_datetime_follows_tz() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2020-01-02', false)! == 1_577_910_600 + _ = os.setenv('TZ', 'America/New_York', true) + assert parse_datetime('2020-01-02', false)! == 1_577_941_200 + _ = os.setenv('TZ', 'UTC', true) + assert parse_datetime('2020-01-02', false)! == 1_577_923_200 + 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', 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', false)! == 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', 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', false)! == 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() +} + +// @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' +} + +// Month names, weekday names and the C locale's m/d/y. Stamps read off GNU 9.4 +// under TZ=Asia/Tehran: +// March 1 2020 -> 1583008200 +// 1/2/2020 -> 1577910600 +// 29 February 2020 -> 1582921800 +// March 1 2020 03:04 -> 1583019240 +fn test_parse_datetime_month_names() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // The month may come first or last, with or without commas, in any case. + assert parse_datetime('March 1 2020', false)! == 1_583_008_200 + assert parse_datetime('1 March 2020', false)! == 1_583_008_200 + assert parse_datetime('March 1, 2020', false)! == 1_583_008_200 + assert parse_datetime('march 1, 2020', false)! == 1_583_008_200 + assert parse_datetime('MARCH 1 2020', false)! == 1_583_008_200 + // The three letter form, and the full one. + assert parse_datetime('Mar 1 2020', false)! == 1_583_008_200 + assert parse_datetime('February 29 2020', false)! == 1_582_921_800 + assert parse_datetime('29 February 2020', false)! == 1_582_921_800 + // A time may follow. + assert parse_datetime('March 1 2020 03:04', false)! == 1_583_019_240 + pass() +} + +// A weekday name is accepted and then ignored, even when it is the wrong one: +// 2020-03-01 was a Sunday, and GNU gives 2020-03-01 for Sun, Mon and Sat alike. +fn test_parse_datetime_weekday_is_ignored() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + plain := parse_datetime('March 1 2020', false)! + assert parse_datetime('Sun March 1 2020', false)! == plain + assert parse_datetime('Mon March 1 2020', false)! == plain + assert parse_datetime('Monday, March 1, 2020', false)! == plain + pass() +} + +// The C locale reads a slashed date month first, and does not fall back to +// day first when the first number cannot be a month. +fn test_parse_datetime_slashed_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // 1/2/2020 is 2 January here, not 1 February. + assert parse_datetime('1/2/2020', false)! == 1_577_910_600 + assert parse_datetime('1/2/2020', false)! == parse_datetime('2020-01-02', false)! + // 2/1/2020 is 1 February. + assert parse_datetime('2/1/2020', false)! == parse_datetime('2020-02-01', false)! + // 13 cannot be a month, and GNU rejects it rather than reading 13 February. + assert_parse_fails('13/2/2020', false) + assert parse_datetime('1/13/2020', false)! == parse_datetime('2020-01-13', false)! + // A two digit year, and a time may follow. + assert parse_datetime('1/2/20', false)! == parse_datetime('2020-01-02', false)! + assert parse_datetime('1/2/2020 03:04:05', false)! == parse_datetime('2020-01-02 03:04:05', false)! + pass() +} + +// A missing year is the current one, so the expectation has to be built rather +// than written down. +fn test_parse_datetime_missing_year_is_this_year() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + loc := time.load_location('Local') or { return } + this_year := time.unix(time.now().unix()).in(loc) or { return }.year + stamp := parse_datetime('1 March', false) or { + assert false, '1 March should parse' + return + } + read_back := time.unix(stamp).in(loc) or { return } + assert read_back.year == this_year + assert read_back.month == 3 + assert read_back.day == 1 + pass() +} + +// GNU checks the day against the month rather than rolling it over, and needs a +// day at all: `March 2020` is an error there too. +fn test_parse_datetime_rejects_impossible_dates() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert_parse_fails('31 April 2020', false) + assert_parse_fails('30 February 2020', false) + assert_parse_fails('March 2020', false) + // 29 February is fine in a leap year and not otherwise. + assert parse_datetime('29 February 2020', false)! != 0 + assert_parse_fails('29 February 2021', false) + pass() +} + +// A bare four digit number is HHMM on its own, but a year once a year is taken. +// Both were measured: `-d 2020` is 20:20 today, and `March 1 2020 0304` is 03:04 +// on 1 March 2020. +fn test_parse_datetime_four_digits_are_ambiguous() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + midnight := parse_datetime('00:00', false)! + assert parse_datetime('2020', false)! == midnight + (20 * 3600 + 20 * 60) + assert parse_datetime('0304', false)! == midnight + (3 * 3600 + 4 * 60) + assert parse_datetime('March 1 2020 0304', false)! == parse_datetime('2020-03-01 03:04', false)! + pass() +} + +// The relative items GNU resolves against the clock. Measured at GNU 9.4, and +// identical in UTC, Asia/Tehran and America/New_York, because a bare item is a +// plain number of seconds and consults no zone at all: +// +// yesterday -86400 tomorrow +86400 +// -1 day -86400 1 day ago -86400 +// last day -86400 next day +86400 +// 2 hours ago -7200 2 hours +7200 +// last week -604800 next week +604800 +// +2 weeks +1209600 1 fortnight +1209600 +// +// The comparison is a window rather than an equality because both sides read the +// clock as they run: parse_datetime calls time.now() and so does the expectation, +// and the second between those two calls is not a disagreement with GNU. +// +// Each zone is entered through a control that has to hold before the deltas are +// read, because a relative item looks the same in every zone. If TZ stopped being +// honoured the deltas below would carry on passing and prove nothing, and the one +// assertion that would notice is a local midnight. +const zone_midnights = { + 'UTC': 1_577_923_200 + 'Asia/Tehran': 1_577_910_600 + 'America/New_York': 1_577_941_200 +} + +fn test_parse_datetime_relative_items() { + p(@METHOD) + for zone, midnight in zone_midnights { + saved := pin_zone(zone) + assert parse_datetime('2020-01-02', false)! == midnight, 'TZ ${zone} not in force' + assert_relative_delta('yesterday', -86_400) + assert_relative_delta('tomorrow', 86_400) + assert_relative_delta('-1 day', -86_400) + assert_relative_delta('1 day ago', -86_400) + assert_relative_delta('last day', -86_400) + assert_relative_delta('next day', 86_400) + assert_relative_delta('this day', 0) + assert_relative_delta('2 hours ago', -7_200) + assert_relative_delta('2 hours', 7_200) + assert_relative_delta('-30 minutes', -1_800) + assert_relative_delta('last week', -604_800) + assert_relative_delta('next week', 604_800) + assert_relative_delta('+2 weeks', 1_209_600) + assert_relative_delta('1 fortnight', 1_209_600) + assert_relative_delta('1 fortnight ago', -1_209_600) + assert_relative_delta('now', 0) + assert_relative_delta('today', 0) + assert_relative_delta('last second', -1) + _ = os.setenv('TZ', saved, true) + } + pass() +} + +// `ago` negates the item it follows and leaves the rest of the string alone, which +// is the opposite of negating the whole thing. Measured at GNU 9.4: +// +// 1 day 2 hours ago +79200 a day forward and two hours back +// 2 hours 1 day ago -79200 the same two items the other way round +// 1 day 1 day +172800 two items, no direction word, both positive +// next day next week +691200 +fn test_parse_datetime_ago_negates_only_its_own_item() { + p(@METHOD) + assert_relative_delta('1 day 2 hours ago', 79_200) + assert_relative_delta('2 hours 1 day ago', -79_200) + assert_relative_delta('1 day 1 day', 172_800) + assert_relative_delta('next day next week', 691_200) + pass() +} + +// The words are read in any case, and `hence` is accepted without changing the +// sign. Both measured at GNU 9.4. +fn test_parse_datetime_relative_words_are_case_insensitive() { + p(@METHOD) + assert_relative_delta('Yesterday', -86_400) + assert_relative_delta('YESTERDAY', -86_400) + assert_relative_delta('Next Week', 604_800) + assert_relative_delta('2 HOURS AGO', -7_200) + assert_relative_delta('1 day hence', 86_400) + pass() +} + +// Whole months are the one item whose number of seconds depends on the day of the +// month, so the arithmetic is pinned on its own here and the wiring is checked by +// the test below it. GNU normalises the overflow rather than clamping it, so +// 31 January is 2 March and not 29 February. +fn test_add_months_carries_an_over_long_day() { + p(@METHOD) + mut year := 0 + mut month := 0 + mut day := 0 + year, month, day = add_months(2020, 1, 31, 1) + assert year == 2020 && month == 3 && day == 2 + year, month, day = add_months(2020, 3, 31, -1) + assert year == 2020 && month == 3 && day == 2 + year, month, day = add_months(2020, 3, 31, 1) + assert year == 2020 && month == 5 && day == 1 + year, month, day = add_months(2020, 2, 29, 12) + assert year == 2021 && month == 3 && day == 1 + // A year is twelve months, and a month before 1970 keeps its own month rather + // than wrapping, which is what floor division is for. + year, month, day = add_months(1969, 12, 31, -12) + assert year == 1968 && month == 12 && day == 31 + pass() +} + +// `last month` has to land a calendar month back and keep the time of day, and a +// month is between 28 and 31 days. The day of the month is left to add_months +// above, because on the 31st the two readings of it differ and only one is right. +fn test_parse_datetime_month_items_move_the_month() { + p(@METHOD) + before := time.now().unix() + got := parse_datetime('last month', false)! + after := time.now().unix() + landed := time.unix(got) + now := time.unix(before) + assert landed.hour == now.hour && landed.minute == now.minute + if now.month == 1 { + assert landed.month == 12 && landed.year == now.year - 1 + } else { + assert landed.month == now.month - 1 && landed.year == now.year + } + assert got <= after && got >= after - 31 * 86400 + assert got >= before - 31 * 86400 && got <= before - 28 * 86400 + pass() +} + +// Spellings and combinations that read like the ones GNU takes and that it +// rejects. Each was measured, and each has to stay rejected: accepting one would +// be a wrong answer rather than a missing feature. +fn test_parse_datetime_rejects_relative_words_gnu_rejects() { + p(@METHOD) + assert_parse_fails('previous day', false) + assert_parse_fails('last 2 days', false) + assert_parse_fails('next 2 weeks', false) + assert_parse_fails('1 day from now', false) + assert_parse_fails('1 day after', false) + assert_parse_fails('1 day before', false) + assert_parse_fails('ago', false) + assert_parse_fails('hence', false) + assert_parse_fails('from now', false) + assert_parse_fails('last', false) + assert_parse_fails('2 weeks yonder', false) + // -t reads the compact stamp and nothing else, so the relative items are not + // in its vocabulary even though they are in -d's. + assert_parse_fails('yesterday', true) + assert_parse_fails('+2 weeks', true) + pass() +} + +// assert_relative_delta checks a relative item against a measured number of +// seconds. parse_datetime reads the clock itself, so the result can only be pinned +// to the second between the two reads: got - want has to land between them. +fn assert_relative_delta(form string, want i64) { + before := time.now().unix() + got := parse_datetime(form, false) or { panic('${form} was rejected') } + after := time.now().unix() + landed := got - want + assert landed >= before && landed <= after, '${form}: ${got} - ${want} = ${landed}, outside [${before}, ${after}]' +} + 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 +673,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 +697,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)! 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)