Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below.

## [Unreleased]

### Fixed
- `os.time` normalises out-of-range date-table fields the way C `mktime` does
(e.g. `day = 0` is the last day of the previous month, `month = 13` is
January of the next year) instead of raising a `MatchError`.

## [1.0.2] - 2026-07-28

### Changed
Expand Down
12 changes: 10 additions & 2 deletions lib/lua/vm/stdlib/os.ex
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,17 @@ defmodule Lua.VM.Stdlib.Os do
end
end

# Lua 5.3 §6.9: date-table fields need not be within their valid ranges —
# C mktime normalises them (day = 0 is the last day of the previous month,
# month = 13 is January of the next year, hour = 25 rolls into the next day).
# Fold the month into the year, anchor on the 1st of that month, then add the
# remaining fields as a signed offset in seconds.
defp naive_to_epoch(%{year: y, month: mo, day: d, hour: h, minute: mi, second: s}) do
{:ok, naive} = NaiveDateTime.new(y, mo, d, h, mi, s)
naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix(:second)
months = y * 12 + (mo - 1)
first_of_month = Date.new!(Integer.floor_div(months, 12), Integer.mod(months, 12) + 1, 1)
base = first_of_month |> DateTime.new!(~T[00:00:00], "Etc/UTC") |> DateTime.to_unix(:second)

base + (d - 1) * 86_400 + h * 3_600 + mi * 60 + s
end

defp strip_utc_flag("!" <> rest), do: {rest, true}
Expand Down
61 changes: 61 additions & 0 deletions test/lua/vm/stdlib/os_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,65 @@ defmodule Lua.VM.Stdlib.OsTest do
end
end
end

# Lua 5.3 §6.9: date-table fields passed to os.time need not be within their
# valid ranges; they are normalised the way C mktime does.
describe "os.time field normalization" do
test "day = 0 is the last day of the previous month" do
assert normalize("year=2026, month=9, day=0") == {2026, 8, 31, 12, 0, 0}
assert normalize("year=2026, month=3, day=0") == {2026, 2, 28, 12, 0, 0}
assert normalize("year=2028, month=3, day=0") == {2028, 2, 29, 12, 0, 0}
end

test "day = 0 of month 1 rolls back into December of the previous year" do
assert normalize("year=2026, month=1, day=0") == {2025, 12, 31, 12, 0, 0}
end

test "month past December rolls into the next year" do
assert normalize("year=2026, month=13, day=1") == {2027, 1, 1, 12, 0, 0}
assert normalize("year=2026, month=12 + 1, day=0") == {2026, 12, 31, 12, 0, 0}
assert normalize("year=2026, month=14, day=1") == {2027, 2, 1, 12, 0, 0}
assert normalize("year=2026, month=24, day=1") == {2027, 12, 1, 12, 0, 0}
assert normalize("year=2026, month=25, day=0") == {2027, 12, 31, 12, 0, 0}
assert normalize("year=2026, month=25, day=1") == {2028, 1, 1, 12, 0, 0}
assert normalize("year=2026, month=100, day=1") == {2034, 4, 1, 12, 0, 0}
end

test "month below January rolls into the previous year" do
assert normalize("year=2026, month=0, day=1") == {2025, 12, 1, 12, 0, 0}
assert normalize("year=2026, month=-1, day=1") == {2025, 11, 1, 12, 0, 0}
assert normalize("year=2026, month=-13, day=1") == {2024, 11, 1, 12, 0, 0}
end

test "day past the end of the month overflows into the next month" do
assert normalize("year=2026, month=2, day=30") == {2026, 3, 2, 12, 0, 0}
assert normalize("year=2028, month=2, day=30") == {2028, 3, 1, 12, 0, 0}
assert normalize("year=2026, month=1, day=32") == {2026, 2, 1, 12, 0, 0}
assert normalize("year=2026, month=1, day=400") == {2027, 2, 4, 12, 0, 0}
end

test "negative day counts back across months and years" do
assert normalize("year=2026, month=1, day=-40") == {2025, 11, 21, 12, 0, 0}
end

test "time fields overflow and underflow across days" do
assert normalize("year=2026, month=12, day=31, hour=25") == {2027, 1, 1, 1, 0, 0}
assert normalize("year=2026, month=1, day=1, hour=0, min=0, sec=-10") == {2025, 12, 31, 23, 59, 50}
assert normalize("year=2026, month=1, day=1, hour=0, min=90") == {2026, 1, 1, 1, 30, 0}
end

test "in-range fields are unchanged" do
assert normalize("year=2000, month=1, day=1, hour=0, min=0, sec=0") == {2000, 1, 1, 0, 0, 0}
end
end

defp normalize(fields) do
code = """
local t = os.date("!*t", os.time({#{fields}}))
return t.year, t.month, t.day, t.hour, t.min, t.sec
"""

{[year, month, day, hour, min, sec], _} = Lua.eval!(code)
{year, month, day, hour, min, sec}
end
end