Skip to content

touch: accept the date forms GNU takes that parse_iso8601 does not - #205

Closed
metif12 wants to merge 3 commits into
vlang:mainfrom
metif12:fix/touch-dates
Closed

metif12 wants to merge 3 commits into
vlang:mainfrom
metif12:fix/touch-dates

Conversation

@metif12

@metif12 metif12 commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

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.

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.
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.
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 vlang#204, which this needs for the local-time reading that bare times
depend on.
metif12 added a commit to metif12/coreutils that referenced this pull request Oct 3, 2026
Twenty three spellings were rejected, the rest of what GNU takes:

	March 1, 2020    1 March 2020    Mar 1 2020    Mon March 2 2020
	Monday, March 2, 2020             1/2/2020     2/1/2020
	1 March                          1/2/20       1/2/2020 03:04:05

Twenty three cases under two zones: 6 of 46 before, 46 of 46 now. The 6 that
already passed were the ones both binaries reject.

Every rule came from GNU. Two of them are the opposite of what the shape
suggests, and both were measured rather than reasoned about:

- A slashed date is **month first**, because that is the C locale's d_fmt:
  `1/2/2020` is 2 January and `2/1/2020` is 1 February. GNU does not fall back
  to day first when the first number cannot be a month, so `13/2/2020` is an
  error rather than 13 February.
- A weekday name is accepted and then **ignored**, even when it is the wrong
  one. 2020-03-01 was a Sunday, and Sun, Mon and Sat all give 2020-03-01.

Also measured, and implemented because they turned up while testing the above:

- Month names ignore case and an optional full stop: MARCH, march and Mar. alike.
- A missing year is the current one, so `1 March` is 1 March of this year.
- The day is checked against the month rather than rolled over: `31 April 2020`
  and `29 February 2021` are errors, while `29 February 2020` is fine.
- A day is required, so `March 2020` is an error.
- A time may follow, with or without seconds.
- `2020-01-02 03:04` and `2020-01-02 03` were being rejected as well, because
  parse_iso8601 insists on HH:MM:SS. The seconds are now supplied.

That last one is where the interesting ambiguity is. A bare four digit number is
HHMM when it stands alone, since `-d 2020` is 20:20 today, but a year once a
year has been taken, since `March 1 2020 0304` is 03:04 on 1 March 2020. Both are
covered by tests.

## Tests

Six more tests. Stamps read off GNU under TZ=Asia/Tehran; the two that cannot be
fixed numbers, a missing year and the bare-time base, are compared against the
same zone's calendar instead.

Making worded_date return none drops the matrix from 46 of 46 to 6 of 46 and
fails the suite at test_parse_datetime_month_names.

`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 vlang#205, which carries vlang#204.
metif12 added a commit to metif12/coreutils that referenced this pull request Oct 3, 2026
On Windows, time.load_location('Local') answers with the zone the machine is set
to whatever the environment says. Measured on this host at +0330:

	TZ=UTC               touch -d '2020-01-02 03:04:05'  ->  1577934245
	TZ=Asia/Tehran       touch -d '2020-01-02 03:04:05'  ->  1577921645
	TZ=America/New_York  touch -d '2020-01-02 03:04:05'  ->  1577952245
	all three, before    touch -d '2020-01-02 03:04:05'  ->  1577921645

The first three are what GNU gives; the last is what this did for all of them.
Those are also the values this repository's own tests assert, so the tests passed
only because this machine is in the one zone they name, and every test file that
pins TZ would fail on a runner set to anything else.

TZ is now read first and 'Local' is the fallback, which is also what POSIX says
it means. A TZ naming 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.

local_today cannot use Time.local() for the same reason, and reads the date as
the epoch moved by the offset now in force and then taken as UTC fields.

## Tests

One new test, and it is the one that would have caught this: the same date read
in three zones inside a single process. A location cached from the first read
would fail it, which is the point.

src/touch is 24 of 24 and `v fmt -verify src/touch` passes.

Stacked on vlang#206, which carries vlang#205 and vlang#204.
@metif12

metif12 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

C:\Users\MR\AppData\Local\Temp\opencode\cu-tmp\close205.txt

@metif12 metif12 closed this Oct 4, 2026
JalonSolov pushed a commit that referenced this pull request Oct 4, 2026
* 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.

* 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.

* 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.

* touch: accept month names, weekday names and the C locale's m/d/y

Twenty three spellings were rejected, the rest of what GNU takes:

	March 1, 2020    1 March 2020    Mar 1 2020    Mon March 2 2020
	Monday, March 2, 2020             1/2/2020     2/1/2020
	1 March                          1/2/20       1/2/2020 03:04:05

Twenty three cases under two zones: 6 of 46 before, 46 of 46 now. The 6 that
already passed were the ones both binaries reject.

Every rule came from GNU. Two of them are the opposite of what the shape
suggests, and both were measured rather than reasoned about:

- A slashed date is **month first**, because that is the C locale's d_fmt:
  `1/2/2020` is 2 January and `2/1/2020` is 1 February. GNU does not fall back
  to day first when the first number cannot be a month, so `13/2/2020` is an
  error rather than 13 February.
- A weekday name is accepted and then **ignored**, even when it is the wrong
  one. 2020-03-01 was a Sunday, and Sun, Mon and Sat all give 2020-03-01.

Also measured, and implemented because they turned up while testing the above:

- Month names ignore case and an optional full stop: MARCH, march and Mar. alike.
- A missing year is the current one, so `1 March` is 1 March of this year.
- The day is checked against the month rather than rolled over: `31 April 2020`
  and `29 February 2021` are errors, while `29 February 2020` is fine.
- A day is required, so `March 2020` is an error.
- A time may follow, with or without seconds.
- `2020-01-02 03:04` and `2020-01-02 03` were being rejected as well, because
  parse_iso8601 insists on HH:MM:SS. The seconds are now supplied.

That last one is where the interesting ambiguity is. A bare four digit number is
HHMM when it stands alone, since `-d 2020` is 20:20 today, but a year once a
year has been taken, since `March 1 2020 0304` is 03:04 on 1 March 2020. Both are
covered by tests.

## Tests

Six more tests. Stamps read off GNU under TZ=Asia/Tehran; the two that cannot be
fixed numbers, a missing year and the bare-time base, are compared against the
same zone's calendar instead.

Making worded_date return none drops the matrix from 46 of 46 to 6 of 46 and
fails the suite at test_parse_datetime_month_names.

`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 #205, which carries #204.

* touch: read a zone-less date in the zone TZ names

On Windows, time.load_location('Local') answers with the zone the machine is set
to whatever the environment says. Measured on this host at +0330:

	TZ=UTC               touch -d '2020-01-02 03:04:05'  ->  1577934245
	TZ=Asia/Tehran       touch -d '2020-01-02 03:04:05'  ->  1577921645
	TZ=America/New_York  touch -d '2020-01-02 03:04:05'  ->  1577952245
	all three, before    touch -d '2020-01-02 03:04:05'  ->  1577921645

The first three are what GNU gives; the last is what this did for all of them.
Those are also the values this repository's own tests assert, so the tests passed
only because this machine is in the one zone they name, and every test file that
pins TZ would fail on a runner set to anything else.

TZ is now read first and 'Local' is the fallback, which is also what POSIX says
it means. A TZ naming 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.

local_today cannot use Time.local() for the same reason, and reads the date as
the epoch moved by the offset now in force and then taken as UTC fields.

## Tests

One new test, and it is the one that would have caught this: the same date read
in three zones inside a single process. A location cached from the first read
would fail it, which is the point.

src/touch is 24 of 24 and `v fmt -verify src/touch` passes.

Stacked on #206, which carries #205 and #204.

* touch: accept the relative items GNU resolves against the clock

Seven of the spellings this port rejected outright:

	-d yesterday      -d tomorrow     -d '2 hours ago'    -d next week
	-d 'last month'    -d '-1 day'     -d '+2 weeks'

Twenty one cases, seven forms in each of three zones: 0 of 21 before, 21 of 21
now, measured end to end against GNU touch 9.4 by writing a file with each
binary and reading back what it wrote. Every zone's first row is a control with
an absolute date, whose stamp does depend on the zone, so a comparison that
passed because TZ had stopped being honoured would show up there.

Two of the rules are the opposite of what the shape of the input suggests, and
both were measured rather than reasoned about:

- 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 reads the
  same in all three zones, which is only possible if the zone is not consulted
  at all.

- `ago` negates the item it follows and leaves the rest of the string alone, so
  `1 day 2 hours ago` is +22 hours and `2 hours 1 day ago` is -22. Negating the
  whole string gives -26 for the first, which is not what GNU prints.

Also measured and implemented:

- A signed count carries its sign and nothing more: `-1 day` is -86400.
- Whole months carry an over-long day into the next month rather than clamping
  it, so 31 January is 2 March and not 29 February.
- `today` and `now` both name this instant. `today` is not midnight: measured
  with the clock at 01:31:18, `date -d today` returned that second.
- The words are read in any case, and any run of spaces separates them.
- `previous day`, `last 2 days`, `next 2 weeks`, `1 day from now`, `1 day after`,
  `1 day before` and a bare `ago` are all rejected, as they are at GNU.
- The items belong to -d alone, so `touch -t yesterday` is an error.

What is not implemented, and no test claims otherwise: a relative item *after* an
absolute date (`2020-01-02 tomorrow`), which GNU takes through a different path
in its parser, and a bare weekday name (`monday`), which names the coming Monday
at midnight. 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 belongs to
the same unimplemented path and is why the comment in relative.v says so.

## Tests

Six new tests. The day, week and hour items are compared as a delta from now
inside a window, because both sides read the clock as they run. Whole months are
the one item whose number of seconds depends on the day of the month, so
add_months is pinned on its own against GNU's table and the wiring is checked
separately, which keeps the test honest on the 31st as well as on the 3rd.

Breaking the `ago` rule again to confirm the suite bites:

	1 day 2 hours ago: 1790987642 - 79200 = 1790908442, outside [1791066842, ...]

One bug found on the way, which is worth naming because it is a V trap rather than
a date trap: reading a missing key out of a map yields the zero value and does
*not* run an `or` block, and the zero value of the unit enum is `.second`. Every
unrecognised word therefore became one second, and `-d '1 March'` parsed as a
second from now. rel_unit now checks `in` first. The pre-existing test for a
missing year is what caught it.

src/touch is 25 of 25 and `v fmt -verify src/touch` passes. The full suite is
31 of 49 files failing locally for want of Windows binaries in bin/, which is the
state this checkout was already in and is unrelated: the only files changed are
under src/touch, and src/touch/touch_test.v is one of the 18 that pass.

Stacked on the TZ commit, which carries #206 and its ancestors.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant