From 2401f68fd5b1fa34a8cdab9d10ba7b56f4c75067 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 29 Nov 2025 21:20:05 -0500 Subject: [PATCH 01/25] Add support for parsing to dynamic --- gleam.toml | 2 +- src/tom.gleam | 276 +++++++++++++++++++++++++++++++++++++++++- src/tom_ffi.erl | 41 +++++++ src/tom_ffi.mjs | 71 +++++++++++ test/tom_test.gleam | 288 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 676 insertions(+), 2 deletions(-) create mode 100644 src/tom_ffi.erl create mode 100644 src/tom_ffi.mjs diff --git a/gleam.toml b/gleam.toml index fa511d6..7fc9fe4 100644 --- a/gleam.toml +++ b/gleam.toml @@ -10,7 +10,7 @@ links = [ ] [dependencies] -gleam_stdlib = ">= 0.33.0 and < 3.0.0" +gleam_stdlib = ">= 0.53.0 and < 3.0.0" gleam_time = ">= 1.2.0 and < 2.0.0" [dev-dependencies] diff --git a/src/tom.gleam b/src/tom.gleam index d9b61be..51b0e11 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -25,6 +25,8 @@ //// ``` import gleam/dict.{type Dict} +import gleam/dynamic.{type Dynamic} +import gleam/dynamic/decode.{type Decoder} import gleam/float import gleam/int import gleam/list @@ -65,6 +67,11 @@ pub type Sign { Negative } +/// A date time value, decoded by the `decode_datetime` function +pub type DateTimeValue { + DateTimeValue(date: calendar.Date, time: calendar.TimeOfDay, offset: Offset) +} + /// An error that can occur when parsing a TOML document. pub type ParseError { /// An unexpected character was encountered when parsing the document. @@ -79,7 +86,7 @@ type Tokens = type Parsed(a) = Result(#(a, Tokens), ParseError) -/// A number of any kind, returned by the `get_number` function. +/// A number of any kind, returned by the `get_number`/`decode_number` functions. pub type Number { NumberInt(Int) NumberFloat(Float) @@ -96,6 +103,16 @@ pub type GetError { WrongType(key: List(String), expected: String, got: String) } +@internal +pub type NanValue { + NanValue(sign: Sign) +} + +@internal +pub type InfinityValue { + InfinityValue(sign: Sign) +} + // TODO: test /// Get a value of any type from a TOML document dictionary. /// @@ -125,6 +142,18 @@ pub fn get( } } +/// Convert a parsed TOML document into a `Dynamic`. +pub fn to_dynamic(toml: Dict(String, Toml)) -> Dynamic { + table_to_dynamic(toml) +} + +/// A convenience for parsing a TOML document and immediately converting it to a Dynamic +pub fn parse_dynamic(input: String) -> Result(Dynamic, ParseError) { + input + |> parse() + |> result.map(to_dynamic) +} + // TODO: test /// Get an int from a TOML document dictionary. /// @@ -1633,3 +1662,248 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { other -> Error(WrongType([], "Number", classify(other))) } } + +/// A decoder that decodes TOML numbers into a `Number`. +/// +/// ## Examples +/// ```gleam +/// let assert Ok(toml) = tom.parse_dynamic("lucy = 1337") +/// decode.run(toml, decode.dict(decode.string, tom.number_decoder()))) +/// // -> Ok(dict.from_list([#("lucy", tom.NumberInt(1337))])) +/// ``` +pub fn number_decoder() -> Decoder(Number) { + decode.new_primitive_decoder("Number", fn(value) { + let decoder = { + decode.one_of(decode.map(decode.int, NumberInt), or: [ + decode.map(nan_decoder(), fn(nan) { NumberNan(nan.sign) }), + decode.map(infinity_decoder(), fn(infinity) { + NumberInfinity(infinity.sign) + }), + // This must come _after_ the infinity decoder, as the stdlib implementation will + // attempt to decode Infinity as a float + decode.map(decode.float, NumberFloat), + ]) + } + + value + |> decode.run(decoder) + |> result.map_error(fn(_error) { NumberInt(0) }) + }) +} + +/// A decoder that decodes TOML date into a `calendar.Date`. +/// +/// ## Examples +/// ```gleam +/// let assert Ok(toml) = tom.parse_dynamic("future = 2015-10-21") +/// decode.run(toml, decode.dict(decode.string, tom.date_decoder()))) +/// // -> Ok(dict.from_list([#("future", calendar.Date(year: 2015, month: calendar.October, day: 21))])) +/// ``` +pub fn date_decoder() -> Decoder(calendar.Date) { + decode.new_primitive_decoder("calendar.Date", fn(value) { + let decoder = { + use year <- decode.field("year", decode.int) + use month <- decode.field("month", month_decoder()) + use day <- decode.field("day", decode.int) + + decode.success(calendar.Date(year:, month:, day:)) + } + + value + |> decode.run(decoder) + |> result.map_error(fn(_error) { + calendar.Date(year: 1970, month: calendar.January, day: 1) + }) + }) +} + +/// A decoder that decodes TOML time into a `calendar.TimeOfDay`. +/// +/// ## Examples +/// ```gleam +/// let assert Ok(toml) = tom.parse_dynamic("time = 07:28:00") +/// decode.run(toml, decode.dict(decode.string, tom.time_decoder()))) +/// // -> Ok(dict.from_list([#("time", calendar.TimeOfDay(hours: 7, minutes: 28, seconds: 0, nanoseconds: 0))])) +/// ``` +pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { + decode.new_primitive_decoder("calendar.Date", fn(value) { + let decoder = { + use hours <- decode.field("hours", decode.int) + use minutes <- decode.field("minutes", decode.int) + use seconds <- decode.field("seconds", decode.int) + use nanoseconds <- decode.field("nanoseconds", decode.int) + + decode.success(calendar.TimeOfDay( + hours:, + minutes:, + seconds:, + nanoseconds:, + )) + } + + value + |> decode.run(decoder) + |> result.map_error(fn(_error) { + calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0) + }) + }) +} + +/// A decoder that decodes TOML datetime into corresponding date, time, and offset parts. +/// +/// ## Examples +/// ```gleam +/// let assert Ok(toml) = tom.parse_dynamic("datetime = 2015-10-21T07:28:00") +/// decode.run(toml, decode.dict(decode.string, tom.datetime_decoder())) +/// // -> Ok(dict.from_list([ +/// // #("datetime", tom.DateTimeValue( +/// // date: calendar.Date(year: 2015, month: calendar.October, day: 21), +/// // time: calendar.TimeOfDay(hours: 7, minutes: 28, seconds: 0, nanoseconds: 0), +/// // offset: tom.Local +/// // )) +/// // ])) +/// ``` +pub fn datetime_decoder() -> Decoder(DateTimeValue) { + decode.new_primitive_decoder("DateTimeValue", fn(value) { + let decoder = { + use date <- decode.field("date", date_decoder()) + use time <- decode.field("time", time_decoder()) + use offset <- decode.field("offset", offset_decoder()) + + decode.success(DateTimeValue(date:, time:, offset:)) + } + + value + |> decode.run(decoder) + |> result.map_error(fn(_error) { + DateTimeValue( + date: calendar.Date(year: 1970, month: calendar.January, day: 1), + time: calendar.TimeOfDay( + hours: 0, + minutes: 0, + seconds: 0, + nanoseconds: 0, + ), + offset: Local, + ) + }) + }) +} + +fn value_to_dynamic(value: Toml) -> Dynamic { + case value { + Int(x) -> dynamic.int(x) + Float(x) -> dynamic.float(x) + Bool(x) -> dynamic.bool(x) + String(x) -> dynamic.string(x) + Nan(sign) -> nan_to_dynamic(NanValue(sign)) + Infinity(sign) -> infinity_to_dynamic(InfinityValue(sign)) + Date(x) -> date_to_dynamic(x) + Time(x) -> time_to_dynamic(x) + DateTime(date, time, offset) -> datetime_to_dynamic(date, time, offset) + Table(x) -> table_to_dynamic(x) + InlineTable(x) -> table_to_dynamic(x) + Array(x) -> + x + |> list.map(value_to_dynamic) + |> dynamic.list() + ArrayOfTables(x) -> + x + |> list.map(table_to_dynamic) + |> dynamic.list() + } +} + +fn table_to_dynamic(toml: Dict(String, Toml)) -> Dynamic { + toml + |> dict.to_list() + |> list.map(fn(entry) { + let #(key, value) = entry + + #(dynamic.string(key), value_to_dynamic(value)) + }) + |> dynamic.properties() +} + +fn nan_decoder() -> Decoder(NanValue) { + decode.new_primitive_decoder("NanValue", nan_from_dynamic) +} + +fn infinity_decoder() -> Decoder(InfinityValue) { + decode.new_primitive_decoder("InfinityValue", infinity_from_dynamic) +} + +fn month_decoder() -> Decoder(calendar.Month) { + decode.new_primitive_decoder("calendar.Month", month_from_dynamic) +} + +fn offset_decoder() -> Decoder(Offset) { + decode.new_primitive_decoder("Offset", offset_from_dynamic) +} + +@external(erlang, "tom_ffi", "nan_to_dynamic") +@external(javascript, "./tom_ffi.mjs", "nan_to_dynamic") +fn nan_to_dynamic(nan: NanValue) -> Dynamic + +@external(erlang, "tom_ffi", "nan_from_dynamic") +@external(javascript, "./tom_ffi.mjs", "nan_from_dynamic") +fn nan_from_dynamic(dynamic: Dynamic) -> Result(NanValue, NanValue) + +@external(erlang, "tom_ffi", "infinity_to_dynamic") +@external(javascript, "./tom_ffi.mjs", "infinity_to_dynamic") +fn infinity_to_dynamic(infinity: InfinityValue) -> Dynamic + +@external(erlang, "tom_ffi", "infinity_from_dynamic") +@external(javascript, "./tom_ffi.mjs", "infinity_from_dynamic") +fn infinity_from_dynamic( + infinity: Dynamic, +) -> Result(InfinityValue, InfinityValue) + +fn date_to_dynamic(date: calendar.Date) -> Dynamic { + dynamic.properties([ + #(dynamic.string("day"), dynamic.int(date.day)), + #(dynamic.string("month"), month_to_dynamic(date.month)), + #(dynamic.string("year"), dynamic.int(date.year)), + ]) +} + +// Month can be trivially converted back/forth between erlang/js/gleam, so an identity function works here +@external(erlang, "tom_ffi", "identity") +@external(javascript, "./tom_ffi.mjs", "identity") +fn month_to_dynamic(month: calendar.Month) -> Dynamic + +@external(erlang, "tom_ffi", "identity_ok") +@external(javascript, "./tom_ffi.mjs", "identity_ok") +fn month_from_dynamic( + dynamic: Dynamic, +) -> Result(calendar.Month, calendar.Month) + +fn time_to_dynamic(time: calendar.TimeOfDay) -> Dynamic { + dynamic.properties([ + #(dynamic.string("hours"), dynamic.int(time.hours)), + #(dynamic.string("minutes"), dynamic.int(time.minutes)), + #(dynamic.string("seconds"), dynamic.int(time.seconds)), + #(dynamic.string("nanoseconds"), dynamic.int(time.nanoseconds)), + ]) +} + +fn datetime_to_dynamic( + date: calendar.Date, + time: calendar.TimeOfDay, + offset: Offset, +) -> Dynamic { + dynamic.properties([ + #(dynamic.string("date"), date_to_dynamic(date)), + #(dynamic.string("time"), time_to_dynamic(time)), + #(dynamic.string("offset"), offset_to_dynamic(offset)), + ]) +} + +// Offset can be trivially converted back/forth between erlang/js/gleam, so an identity function works here +@external(erlang, "tom_ffi", "identity") +@external(javascript, "./tom_ffi.mjs", "identity") +fn offset_to_dynamic(offset: Offset) -> Dynamic + +@external(erlang, "tom_ffi", "identity_ok") +@external(javascript, "./tom_ffi.mjs", "identity_ok") +fn offset_from_dynamic(dynamic: Dynamic) -> Result(Offset, Offset) diff --git a/src/tom_ffi.erl b/src/tom_ffi.erl new file mode 100644 index 0000000..e5a0dce --- /dev/null +++ b/src/tom_ffi.erl @@ -0,0 +1,41 @@ +-module(tom_ffi). +-export([ + identity/1, + identity_ok/1, + infinity_to_dynamic/1, + infinity_from_dynamic/1, + nan_to_dynamic/1, + nan_from_dynamic/1 + ]). + +nan_to_dynamic({nan_value, positive}) -> + positive_nan; +nan_to_dynamic({nan_value, negative}) -> + negative_nan. + +nan_from_dynamic(positive_nan) -> + {ok, {nan_value, positive}}; +nan_from_dynamic(negative_nan) -> + {ok, {nan_value, negative}}; +nan_from_dynamic(_Other) -> + % value here is a placeholder + {error, {nan_value, positive}}. + +infinity_to_dynamic({infinity_value, positive}) -> + positive_infinity; +infinity_to_dynamic({infinity_value, negative}) -> + negative_infinity. + +infinity_from_dynamic(positive_infinity) -> + {ok, {infinity_value, positive}}; +infinity_from_dynamic(negative_infinity) -> + {ok, {infinity_value, negative}}; +infinity_from_dynamic(_Other) -> + % value here is a placeholder + {error, {infinity_value, positive}}. + +identity(X) -> + X. + +identity_ok(X) -> + {ok, X}. diff --git a/src/tom_ffi.mjs b/src/tom_ffi.mjs new file mode 100644 index 0000000..019039e --- /dev/null +++ b/src/tom_ffi.mjs @@ -0,0 +1,71 @@ +import { Result$Ok, Result$Error } from "./gleam.mjs"; +import { + Sign$Positive, + Sign$Negative, + Sign$isPositive, + Sign$isNegative, + InfinityValue$InfinityValue$sign, + InfinityValue$InfinityValue, + NanValue$NanValue$sign, + NanValue$NanValue, +} from "./tom.mjs"; + +// We can't represent positive/negative NaNs in JS so we must have a representation for them +const negativeNaN = Symbol("-NaN"); +const positiveNaN = Symbol("+NaN"); + +export function nan_to_dynamic(nan_value) { + let sign = NanValue$NanValue$sign(nan_value); + + if (Sign$isPositive(sign)) { + return positiveNaN; + } else if (Sign$isNegative(sign)) { + return negativeNaN; + } else { + // Should never happen by the type system + throw "value is not a nan"; + } +} + +export function nan_from_dynamic(value) { + if (value == positiveNaN) { + return Result$Ok(NanValue$NanValue(Sign$Positive())); + } else if (value == negativeNaN) { + return Result$Ok(NanValue$NanValue(Sign$Negative())); + } else { + // value here is a placeholder + return Result$Error(NanValue$NanValue(Sign$Positive())); + } +} + +export function infinity_to_dynamic(infinity_value) { + let sign = InfinityValue$InfinityValue$sign(infinity_value); + + if (Sign$isPositive(sign)) { + return Infinity; + } else if (Sign$isNegative(sign)) { + return -Infinity; + } else { + // Should never happen by the type system + throw "value is not an infinity"; + } +} + +export function infinity_from_dynamic(value) { + if (value == Infinity) { + return Result$Ok(InfinityValue$InfinityValue(Sign$Positive())); + } else if (value == -Infinity) { + return Result$Ok(InfinityValue$InfinityValue(Sign$Negative())); + } else { + // value here is a placeholder + return Result$Error(NanValue$NanValue(Sign$Positive())); + } +} + +export function identity(value) { + return value; +} + +export function identity_ok(value) { + return Result$Ok(value); +} diff --git a/test/tom_test.gleam b/test/tom_test.gleam index 935e7a1..7a7861a 100644 --- a/test/tom_test.gleam +++ b/test/tom_test.gleam @@ -1,5 +1,10 @@ import gleam/dict +import gleam/dynamic/decode +import gleam/int +import gleam/list +import gleam/order import gleam/result +import gleam/string import gleam/time/calendar import gleam/time/duration import gleam/time/timestamp @@ -1064,3 +1069,286 @@ pub fn get_timestamp_test() { assert tom.get_timestamp(parsed, ["a", "b", "c"]) == Error(tom.WrongType(["a"], "Table", "Int")) } + +pub fn to_dynamic_integer_test() { + let assert Ok(parsed) = tom.parse("a = 5") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", decode.int) + decode.success(a_field) + } + assert decode.run(dynamic, decoder) == Ok(5) +} + +pub fn to_dynamic_nan_test() { + let assert Ok(parsed) = tom.parse("a = nan") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a <- decode.field("a", tom.number_decoder()) + decode.success(a) + } + assert decode.run(dynamic, decoder) == Ok(tom.NumberNan(tom.Positive)) +} + +pub fn to_dynamic_nan_positive_test() { + let assert Ok(parsed) = tom.parse("a = +nan") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a <- decode.field("a", tom.number_decoder()) + decode.success(a) + } + assert decode.run(dynamic, decoder) == Ok(tom.NumberNan(tom.Positive)) +} + +pub fn to_dynamic_nan_negative_test() { + let assert Ok(parsed) = tom.parse("a = -nan") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a <- decode.field("a", tom.number_decoder()) + decode.success(a) + } + assert decode.run(dynamic, decoder) == Ok(tom.NumberNan(tom.Negative)) +} + +pub fn to_dynamic_infinity_test() { + let assert Ok(parsed) = tom.parse("a = inf") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a <- decode.field("a", tom.number_decoder()) + decode.success(a) + } + assert decode.run(dynamic, decoder) == Ok(tom.NumberInfinity(tom.Positive)) +} + +pub fn to_dynamic_infinity_positive_test() { + let assert Ok(parsed) = tom.parse("a = +inf") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a <- decode.field("a", tom.number_decoder()) + decode.success(a) + } + assert decode.run(dynamic, decoder) == Ok(tom.NumberInfinity(tom.Positive)) +} + +pub fn to_dynamic_infinity_negative_test() { + let assert Ok(parsed) = tom.parse("a = -inf") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a <- decode.field("a", tom.number_decoder()) + decode.success(a) + } + assert decode.run(dynamic, decoder) == Ok(tom.NumberInfinity(tom.Negative)) +} + +pub fn to_dynamic_string_test() { + let assert Ok(parsed) = tom.parse("a = \"Hello, Joe\"") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", decode.string) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) == Ok("Hello, Joe") +} + +pub fn to_dynamic_bool_test() { + let assert Ok(parsed) = tom.parse("a = false") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", decode.bool) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) == Ok(False) +} + +pub fn to_dynamic_array_test() { + let assert Ok(parsed) = tom.parse("a = [1, 2, 3]") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", decode.list(decode.int)) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) == Ok([1, 2, 3]) +} + +pub fn to_dynamic_table_test() { + let assert Ok(parsed) = + tom.parse( + " + [a] + a = 1 + b = 2 + c = 3 + ", + ) + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_table_field <- decode.field( + "a", + decode.dict(decode.string, decode.int), + ) + + decode.success(a_table_field) + } + + let decoded = + dynamic + |> decode.run(decoder) + |> result.map(fn(value) { + value + |> dict.to_list() + |> list.sort(fn(a, b) { + let #(a_1, a_2) = a + let #(b_1, b_2) = b + + order.break_tie(string.compare(a_1, b_1), int.compare(a_2, b_2)) + }) + }) + + assert decoded == Ok([#("a", 1), #("b", 2), #("c", 3)]) +} + +pub fn to_dynamic_inline_table_test() { + let assert Ok(parsed) = tom.parse("a = {a = 1, b = 2, c = 3}") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_table_field <- decode.field( + "a", + decode.dict(decode.string, decode.int), + ) + + decode.success(a_table_field) + } + + let decoded = + dynamic + |> decode.run(decoder) + |> result.map(fn(value) { + value + |> dict.to_list() + |> list.sort(fn(a, b) { + let #(a_1, a_2) = a + let #(b_1, b_2) = b + + order.break_tie(string.compare(a_1, b_1), int.compare(a_2, b_2)) + }) + }) + + assert decoded == Ok([#("a", 1), #("b", 2), #("c", 3)]) +} + +pub fn to_dynamic_array_of_tables_test() { + let assert Ok(parsed) = + tom.parse( + "[[a]] + a = 1 + b = 2 + c = 3 + [[a]] + a = 4 + b = 5 + c = 6 + [[a]] + a = 7 + b = 8 + c = 9 + ", + ) + + let dynamic = tom.to_dynamic(parsed) + let decode_table = { + use a_field <- decode.field("a", decode.int) + use b_field <- decode.field("b", decode.int) + use c_field <- decode.field("c", decode.int) + + decode.success(#(a_field, b_field, c_field)) + } + + let decode = { + use a_field <- decode.field("a", decode.list(decode_table)) + decode.success(a_field) + } + + let decoded = + dynamic + |> decode.run(decode) + |> result.map(fn(value) { + list.sort(value, fn(a, b) { + let #(a_1, a_2, a_3) = a + let #(b_1, b_2, b_3) = b + + int.compare(a_1, b_1) + |> order.break_tie(int.compare(a_2, b_2)) + |> order.break_tie(int.compare(a_3, b_3)) + }) + }) + assert decoded == Ok([#(1, 2, 3), #(4, 5, 6), #(7, 8, 9)]) +} + +pub fn to_dynamic_date_test() { + let assert Ok(parsed) = tom.parse("a = 1979-05-27") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.date_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) + == Ok(calendar.Date(year: 1979, month: calendar.May, day: 27)) +} + +pub fn to_dynamic_time_test() { + let assert Ok(parsed) = tom.parse("a = 07:32:01") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.time_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) + == Ok(calendar.TimeOfDay(hours: 7, minutes: 32, seconds: 1, nanoseconds: 0)) +} + +pub fn to_dynamic_datetime_test() { + let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00Z") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.datetime_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) + == Ok(tom.DateTimeValue( + date: calendar.Date(year: 1979, month: calendar.May, day: 27), + time: calendar.TimeOfDay( + hours: 7, + minutes: 32, + seconds: 0, + nanoseconds: 0, + ), + offset: tom.Offset(calendar.utc_offset), + )) +} + +pub fn to_dynamic_datetime_local_offset_test() { + let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.datetime_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) + == Ok(tom.DateTimeValue( + date: calendar.Date(year: 1979, month: calendar.May, day: 27), + time: calendar.TimeOfDay( + hours: 7, + minutes: 32, + seconds: 0, + nanoseconds: 0, + ), + offset: tom.Local, + )) +} From 392566dfddda698daa69b383ccf040f391adb830 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 29 Nov 2025 21:28:36 -0500 Subject: [PATCH 02/25] Add JS target to tests --- .github/workflows/test.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46bc880..8f7c741 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,26 @@ on: jobs: test: + name: Run Tests (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + matrix: + target: [erlang, javascript] + steps: + - uses: actions/checkout@v3 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.14.0" + rebar3-version: "3" + - uses: actions/setup-node@v4 + if: matrix.target == 'javascript' + with: + node-version: 20 + - run: gleam test --target ${{ matrix.target }} + + check-format: + name: Check Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -17,6 +37,4 @@ jobs: otp-version: "28" gleam-version: "1.14.0" rebar3-version: "3" - # elixir-version: "1.15.4" - - run: gleam test - run: gleam format --check src test From 34916e083816c81f1742c0cd53567a5eac7a7499 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:16:59 -0500 Subject: [PATCH 03/25] Fixup code from review comments --- manifest.toml | 2 +- src/tom.gleam | 242 +++++++++++++++++++++++++++--------------------- src/tom_ffi.erl | 7 -- src/tom_ffi.mjs | 8 -- 4 files changed, 138 insertions(+), 121 deletions(-) diff --git a/manifest.toml b/manifest.toml index 0bedd3e..4692699 100644 --- a/manifest.toml +++ b/manifest.toml @@ -8,6 +8,6 @@ packages = [ ] [requirements] -gleam_stdlib = { version = ">= 0.33.0 and < 3.0.0" } +gleam_stdlib = { version = ">= 0.53.0 and < 3.0.0" } gleam_time = { version = ">= 1.2.0 and < 2.0.0" } gleeunit = { version = "~> 1.0" } diff --git a/src/tom.gleam b/src/tom.gleam index 51b0e11..95a4aad 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -147,8 +147,9 @@ pub fn to_dynamic(toml: Dict(String, Toml)) -> Dynamic { table_to_dynamic(toml) } -/// A convenience for parsing a TOML document and immediately converting it to a Dynamic -pub fn parse_dynamic(input: String) -> Result(Dynamic, ParseError) { +/// A convenience for parsing a TOML document and immediately converting it to +/// a `Dynamic`. +pub fn parse_to_dynamic(input: String) -> Result(Dynamic, ParseError) { input |> parse() |> result.map(to_dynamic) @@ -1672,122 +1673,95 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { /// // -> Ok(dict.from_list([#("lucy", tom.NumberInt(1337))])) /// ``` pub fn number_decoder() -> Decoder(Number) { - decode.new_primitive_decoder("Number", fn(value) { - let decoder = { - decode.one_of(decode.map(decode.int, NumberInt), or: [ - decode.map(nan_decoder(), fn(nan) { NumberNan(nan.sign) }), - decode.map(infinity_decoder(), fn(infinity) { - NumberInfinity(infinity.sign) - }), - // This must come _after_ the infinity decoder, as the stdlib implementation will - // attempt to decode Infinity as a float - decode.map(decode.float, NumberFloat), - ]) - } - - value - |> decode.run(decoder) - |> result.map_error(fn(_error) { NumberInt(0) }) - }) + decode.one_of(decode.map(decode.int, NumberInt), or: [ + decode.map(nan_decoder(), fn(nan) { NumberNan(nan.sign) }), + decode.map(infinity_decoder(), fn(infinity) { + NumberInfinity(infinity.sign) + }), + // This must come _after_ the infinity decoder, as the stdlib implementation will + // attempt to decode Infinity as a float + decode.map(decode.float, NumberFloat), + ]) } /// A decoder that decodes TOML date into a `calendar.Date`. /// /// ## Examples +/// /// ```gleam /// let assert Ok(toml) = tom.parse_dynamic("future = 2015-10-21") /// decode.run(toml, decode.dict(decode.string, tom.date_decoder()))) -/// // -> Ok(dict.from_list([#("future", calendar.Date(year: 2015, month: calendar.October, day: 21))])) +/// // -> Ok( +/// // dict.from_list([ +/// // #("future", calendar.Date( +/// // year: 2015, month: calendar.October, day: 21 +/// // )), +/// // ]) +/// // ) /// ``` +/// pub fn date_decoder() -> Decoder(calendar.Date) { - decode.new_primitive_decoder("calendar.Date", fn(value) { - let decoder = { - use year <- decode.field("year", decode.int) - use month <- decode.field("month", month_decoder()) - use day <- decode.field("day", decode.int) - - decode.success(calendar.Date(year:, month:, day:)) - } + use year <- decode.field("year", decode.int) + use month <- decode.field("month", month_decoder()) + use day <- decode.field("day", decode.int) - value - |> decode.run(decoder) - |> result.map_error(fn(_error) { - calendar.Date(year: 1970, month: calendar.January, day: 1) - }) - }) + decode.success(calendar.Date(year:, month:, day:)) } /// A decoder that decodes TOML time into a `calendar.TimeOfDay`. /// /// ## Examples +/// /// ```gleam /// let assert Ok(toml) = tom.parse_dynamic("time = 07:28:00") /// decode.run(toml, decode.dict(decode.string, tom.time_decoder()))) -/// // -> Ok(dict.from_list([#("time", calendar.TimeOfDay(hours: 7, minutes: 28, seconds: 0, nanoseconds: 0))])) +/// // -> Ok( +/// // dict.from_list([ +/// // #("time", calendar.TimeOfDay( +/// // hours: 7, minutes: 28, seconds: 0, nanoseconds: 0 +/// // )) +/// // ]) +/// // ) /// ``` +/// pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { - decode.new_primitive_decoder("calendar.Date", fn(value) { - let decoder = { - use hours <- decode.field("hours", decode.int) - use minutes <- decode.field("minutes", decode.int) - use seconds <- decode.field("seconds", decode.int) - use nanoseconds <- decode.field("nanoseconds", decode.int) - - decode.success(calendar.TimeOfDay( - hours:, - minutes:, - seconds:, - nanoseconds:, - )) - } + use hours <- decode.field("hours", decode.int) + use minutes <- decode.field("minutes", decode.int) + use seconds <- decode.field("seconds", decode.int) + use nanoseconds <- decode.field("nanoseconds", decode.int) - value - |> decode.run(decoder) - |> result.map_error(fn(_error) { - calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0) - }) - }) + decode.success(calendar.TimeOfDay(hours:, minutes:, seconds:, nanoseconds:)) } -/// A decoder that decodes TOML datetime into corresponding date, time, and offset parts. +/// A decoder that decodes TOML datetime into corresponding date, time, and +/// offset parts. /// /// ## Examples +/// /// ```gleam /// let assert Ok(toml) = tom.parse_dynamic("datetime = 2015-10-21T07:28:00") /// decode.run(toml, decode.dict(decode.string, tom.datetime_decoder())) -/// // -> Ok(dict.from_list([ -/// // #("datetime", tom.DateTimeValue( -/// // date: calendar.Date(year: 2015, month: calendar.October, day: 21), -/// // time: calendar.TimeOfDay(hours: 7, minutes: 28, seconds: 0, nanoseconds: 0), -/// // offset: tom.Local -/// // )) -/// // ])) +/// // -> Ok( +/// // dict.from_list([ +/// // #("datetime", tom.DateTimeValue( +/// // date: calendar.Date( +/// // year: 2015, month: calendar.October, day: 21 +/// // ), +/// // time: calendar.TimeOfDay( +/// // hours: 7, minutes: 28, seconds: 0, nanoseconds: 0 +/// // ), +/// // offset: tom.Local +/// // )) +/// // ]) +/// // ) /// ``` +/// pub fn datetime_decoder() -> Decoder(DateTimeValue) { - decode.new_primitive_decoder("DateTimeValue", fn(value) { - let decoder = { - use date <- decode.field("date", date_decoder()) - use time <- decode.field("time", time_decoder()) - use offset <- decode.field("offset", offset_decoder()) + use date <- decode.field("date", date_decoder()) + use time <- decode.field("time", time_decoder()) + use offset <- decode.field("offset", offset_decoder()) - decode.success(DateTimeValue(date:, time:, offset:)) - } - - value - |> decode.run(decoder) - |> result.map_error(fn(_error) { - DateTimeValue( - date: calendar.Date(year: 1970, month: calendar.January, day: 1), - time: calendar.TimeOfDay( - hours: 0, - minutes: 0, - seconds: 0, - nanoseconds: 0, - ), - offset: Local, - ) - }) - }) + decode.success(DateTimeValue(date:, time:, offset:)) } fn value_to_dynamic(value: Toml) -> Dynamic { @@ -1834,11 +1808,54 @@ fn infinity_decoder() -> Decoder(InfinityValue) { } fn month_decoder() -> Decoder(calendar.Month) { - decode.new_primitive_decoder("calendar.Month", month_from_dynamic) + use month_name <- decode.then(decode.string) + + case month_name { + "January" -> decode.success(calendar.January) + "February" -> decode.success(calendar.February) + "March" -> decode.success(calendar.March) + "April" -> decode.success(calendar.April) + "May" -> decode.success(calendar.May) + "June" -> decode.success(calendar.June) + "July" -> decode.success(calendar.July) + "August" -> decode.success(calendar.August) + "September" -> decode.success(calendar.September) + "October" -> decode.success(calendar.October) + "November" -> decode.success(calendar.November) + "December" -> decode.success(calendar.December) + _ -> decode.failure(calendar.January, "month string") + } } fn offset_decoder() -> Decoder(Offset) { - decode.new_primitive_decoder("Offset", offset_from_dynamic) + use variant <- decode.field("type", decode.string) + + case variant { + "Local" -> decode.success(Local) + "Offset" -> { + use duration <- decode.field("duration", duration_decoder()) + + decode.success(Offset(duration)) + } + + _ -> decode.failure(Local, "Offset") + } +} + +fn duration_decoder() -> Decoder(duration.Duration) { + use raw_duration <- decode.then(decode.list(decode.int)) + + case raw_duration { + [seconds, nanos] -> { + let seconds_duration = duration.seconds(seconds) + let nanos_duration = duration.nanoseconds(nanos) + let full_duration = duration.add(seconds_duration, nanos_duration) + + decode.success(full_duration) + } + + _ -> decode.failure(duration.seconds(0), "Duration") + } } @external(erlang, "tom_ffi", "nan_to_dynamic") @@ -1867,16 +1884,22 @@ fn date_to_dynamic(date: calendar.Date) -> Dynamic { ]) } -// Month can be trivially converted back/forth between erlang/js/gleam, so an identity function works here -@external(erlang, "tom_ffi", "identity") -@external(javascript, "./tom_ffi.mjs", "identity") -fn month_to_dynamic(month: calendar.Month) -> Dynamic - -@external(erlang, "tom_ffi", "identity_ok") -@external(javascript, "./tom_ffi.mjs", "identity_ok") -fn month_from_dynamic( - dynamic: Dynamic, -) -> Result(calendar.Month, calendar.Month) +fn month_to_dynamic(month: calendar.Month) -> Dynamic { + case month { + calendar.January -> dynamic.string("January") + calendar.February -> dynamic.string("February") + calendar.March -> dynamic.string("March") + calendar.April -> dynamic.string("April") + calendar.May -> dynamic.string("May") + calendar.June -> dynamic.string("June") + calendar.July -> dynamic.string("July") + calendar.August -> dynamic.string("August") + calendar.September -> dynamic.string("September") + calendar.October -> dynamic.string("October") + calendar.November -> dynamic.string("November") + calendar.December -> dynamic.string("December") + } +} fn time_to_dynamic(time: calendar.TimeOfDay) -> Dynamic { dynamic.properties([ @@ -1899,11 +1922,20 @@ fn datetime_to_dynamic( ]) } -// Offset can be trivially converted back/forth between erlang/js/gleam, so an identity function works here -@external(erlang, "tom_ffi", "identity") -@external(javascript, "./tom_ffi.mjs", "identity") -fn offset_to_dynamic(offset: Offset) -> Dynamic +fn offset_to_dynamic(offset: Offset) -> Dynamic { + case offset { + Local -> + dynamic.properties([#(dynamic.string("type"), dynamic.string("Local"))]) + Offset(duration) -> + dynamic.properties([ + #(dynamic.string("type"), dynamic.string("Offset")), + #(dynamic.string("duration"), duration_to_dynamic(duration)), + ]) + } +} -@external(erlang, "tom_ffi", "identity_ok") -@external(javascript, "./tom_ffi.mjs", "identity_ok") -fn offset_from_dynamic(dynamic: Dynamic) -> Result(Offset, Offset) +fn duration_to_dynamic(duration: duration.Duration) -> Dynamic { + let #(seconds, nanos) = duration.to_seconds_and_nanoseconds(duration) + + dynamic.array([dynamic.int(seconds), dynamic.int(nanos)]) +} diff --git a/src/tom_ffi.erl b/src/tom_ffi.erl index e5a0dce..d5b290e 100644 --- a/src/tom_ffi.erl +++ b/src/tom_ffi.erl @@ -1,7 +1,5 @@ -module(tom_ffi). -export([ - identity/1, - identity_ok/1, infinity_to_dynamic/1, infinity_from_dynamic/1, nan_to_dynamic/1, @@ -34,8 +32,3 @@ infinity_from_dynamic(_Other) -> % value here is a placeholder {error, {infinity_value, positive}}. -identity(X) -> - X. - -identity_ok(X) -> - {ok, X}. diff --git a/src/tom_ffi.mjs b/src/tom_ffi.mjs index 019039e..9c7c589 100644 --- a/src/tom_ffi.mjs +++ b/src/tom_ffi.mjs @@ -61,11 +61,3 @@ export function infinity_from_dynamic(value) { return Result$Error(NanValue$NanValue(Sign$Positive())); } } - -export function identity(value) { - return value; -} - -export function identity_ok(value) { - return Result$Ok(value); -} From cb90f2063aa6f12ce54e9548011f901a8c592347 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:20:13 -0500 Subject: [PATCH 04/25] Remove test matrix --- .github/workflows/test.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f7c741..e29c545 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,12 +8,21 @@ on: pull_request: jobs: - test: - name: Run Tests (${{ matrix.target }}) + test-erlang: + name: Run Tests (Erlang) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.13.0" + rebar3-version: "3" + - run: gleam test --target erlang + + test-javascript: + name: Run Tests (JavaScript) runs-on: ubuntu-latest - strategy: - matrix: - target: [erlang, javascript] steps: - uses: actions/checkout@v3 - uses: erlef/setup-beam@v1 @@ -22,10 +31,9 @@ jobs: gleam-version: "1.14.0" rebar3-version: "3" - uses: actions/setup-node@v4 - if: matrix.target == 'javascript' with: node-version: 20 - - run: gleam test --target ${{ matrix.target }} + - run: gleam test --target javascript check-format: name: Check Format From fbb70206e376463993f4dc29471676efcf199603 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:31:33 -0500 Subject: [PATCH 05/25] Expand dynamic docs --- src/tom.gleam | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 95a4aad..97abdc6 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -142,13 +142,52 @@ pub fn get( } } -/// Convert a parsed TOML document into a `Dynamic`. +/// Convert a parsed TOML document into a `Dynamic`. This can be used to build +/// complex decoders based on TOML data, using +/// [`gleam/dynamic/decode`](https://hexdocs.pm/gleam_stdlib/0.68.1/gleam/dynamic/decode.html). +/// Decoders are provided in this library for TOML-specific types. +/// +/// ## Examples +/// +/// ```gleam +/// let config = "name = \"Lucy\"\npoints = 5" +/// let assert Ok(parsed) = parse(config) +/// let dynamic = to_dynamic(parsed) +/// +/// let decoder = { +/// use name <- decode.field("name", decode.string) +/// use points <- decode.field("points", decode.int) +/// decode.success(#(name, points)) +/// } +/// +/// decode.run(dynamic, decoder) +/// // -> Ok(#("Lucy", 5)) +/// ``` pub fn to_dynamic(toml: Dict(String, Toml)) -> Dynamic { table_to_dynamic(toml) } /// A convenience for parsing a TOML document and immediately converting it to -/// a `Dynamic`. +/// a `Dynamic`. This can be used to build complex decoders based on TOML data, +/// using +/// [`gleam/dynamic/decode`](https://hexdocs.pm/gleam_stdlib/0.68.1/gleam/dynamic/decode.html). +/// Decoders are provided in this library for TOML-specific types. +/// +/// ## Examples +/// +/// ```gleam +/// let config = "name = \"Lucy\"\npoints = 5" +/// let assert Ok(dynamic) = parse_to_dynamic(config) +/// +/// let decoder = { +/// use name <- decode.field("name", decode.string) +/// use points <- decode.field("points", decode.int) +/// decode.success(#(name, points)) +/// } +/// +/// decode.run(dynamic, decoder) +/// // -> Ok(#("Lucy", 5)) +/// ``` pub fn parse_to_dynamic(input: String) -> Result(Dynamic, ParseError) { input |> parse() @@ -159,7 +198,7 @@ pub fn parse_to_dynamic(input: String) -> Result(Dynamic, ParseError) { /// Get an int from a TOML document dictionary. /// /// ## Examples -/// +/// /// ```gleam /// let assert Ok(parsed) = parse("a.b.c = 1") /// get_int(parsed, ["a", "b", "c"]) From c3c543d12663f1213069087cc3e2745ff5862ad9 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:32:41 -0500 Subject: [PATCH 06/25] Formatting fixes --- src/tom.gleam | 4 ++-- src/tom_ffi.erl | 34 +++++++++++++++++----------------- src/tom_ffi.mjs | 4 ++-- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 97abdc6..27d710a 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1717,8 +1717,8 @@ pub fn number_decoder() -> Decoder(Number) { decode.map(infinity_decoder(), fn(infinity) { NumberInfinity(infinity.sign) }), - // This must come _after_ the infinity decoder, as the stdlib implementation will - // attempt to decode Infinity as a float + // This must come _after_ the infinity decoder, as the stdlib implementation + // will attempt to decode Infinity as a float decode.map(decode.float, NumberFloat), ]) } diff --git a/src/tom_ffi.erl b/src/tom_ffi.erl index d5b290e..57038ed 100644 --- a/src/tom_ffi.erl +++ b/src/tom_ffi.erl @@ -1,34 +1,34 @@ -module(tom_ffi). -export([ - infinity_to_dynamic/1, - infinity_from_dynamic/1, - nan_to_dynamic/1, - nan_from_dynamic/1 - ]). + infinity_to_dynamic/1, + infinity_from_dynamic/1, + nan_to_dynamic/1, + nan_from_dynamic/1 +]). nan_to_dynamic({nan_value, positive}) -> - positive_nan; + positive_nan; nan_to_dynamic({nan_value, negative}) -> - negative_nan. + negative_nan. nan_from_dynamic(positive_nan) -> - {ok, {nan_value, positive}}; + {ok, {nan_value, positive}}; nan_from_dynamic(negative_nan) -> - {ok, {nan_value, negative}}; + {ok, {nan_value, negative}}; nan_from_dynamic(_Other) -> - % value here is a placeholder - {error, {nan_value, positive}}. + % Value here is a placeholder + {error, {nan_value, positive}}. infinity_to_dynamic({infinity_value, positive}) -> - positive_infinity; + positive_infinity; infinity_to_dynamic({infinity_value, negative}) -> - negative_infinity. + negative_infinity. infinity_from_dynamic(positive_infinity) -> - {ok, {infinity_value, positive}}; + {ok, {infinity_value, positive}}; infinity_from_dynamic(negative_infinity) -> - {ok, {infinity_value, negative}}; + {ok, {infinity_value, negative}}; infinity_from_dynamic(_Other) -> - % value here is a placeholder - {error, {infinity_value, positive}}. + % Value here is a placeholder + {error, {infinity_value, positive}}. diff --git a/src/tom_ffi.mjs b/src/tom_ffi.mjs index 9c7c589..25c024a 100644 --- a/src/tom_ffi.mjs +++ b/src/tom_ffi.mjs @@ -33,7 +33,7 @@ export function nan_from_dynamic(value) { } else if (value == negativeNaN) { return Result$Ok(NanValue$NanValue(Sign$Negative())); } else { - // value here is a placeholder + // Value here is a placeholder return Result$Error(NanValue$NanValue(Sign$Positive())); } } @@ -57,7 +57,7 @@ export function infinity_from_dynamic(value) { } else if (value == -Infinity) { return Result$Ok(InfinityValue$InfinityValue(Sign$Negative())); } else { - // value here is a placeholder + // Value here is a placeholder return Result$Error(NanValue$NanValue(Sign$Positive())); } } From 93fa84fc58f7fd6b31a2ac2b74bde9879e5a5e96 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:37:18 -0500 Subject: [PATCH 07/25] Add test to confirm non-zero offset parsing --- test/tom_test.gleam | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/tom_test.gleam b/test/tom_test.gleam index 7a7861a..21acdb1 100644 --- a/test/tom_test.gleam +++ b/test/tom_test.gleam @@ -1352,3 +1352,24 @@ pub fn to_dynamic_datetime_local_offset_test() { offset: tom.Local, )) } + +pub fn to_dynamic_datetime_numeric_offset_offset_test() { + let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00-05:00") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.datetime_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) + == Ok(tom.DateTimeValue( + date: calendar.Date(year: 1979, month: calendar.May, day: 27), + time: calendar.TimeOfDay( + hours: 7, + minutes: 32, + seconds: 0, + nanoseconds: 0, + ), + offset: tom.Offset(duration.hours(-5)), + )) +} From e05bb05dacf774d80c1a47d5bbcc14f4b6136c0f Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:41:40 -0500 Subject: [PATCH 08/25] Heading newlines --- src/tom.gleam | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 27d710a..a674859 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -220,7 +220,7 @@ pub fn get_int( /// Get a float from a TOML document dictionary. /// /// ## Examples -/// +/// /// ```gleam /// let assert Ok(parsed) = parse("a.b.c = 1.1") /// get_float(parsed, ["a", "b", "c"]) @@ -242,7 +242,7 @@ pub fn get_float( /// Get a bool from a TOML document dictionary. /// /// ## Examples -/// +/// /// ```gleam /// let assert Ok(parsed) = parse("a.b.c = true") /// get_bool(parsed, ["a", "b", "c"]) @@ -264,7 +264,7 @@ pub fn get_bool( /// Get a string from a TOML document dictionary. /// /// ## Examples -/// +/// /// ```gleam /// let assert Ok(parsed) = parse("a.b.c = \"ok\"") /// get_string(parsed, ["a", "b", "c"]) @@ -285,7 +285,7 @@ pub fn get_string( /// Get a date from a TOML document dictionary. /// /// ## Examples -/// +/// /// ```gleam /// let assert Ok(parsed) = parse("a.b.c = 1979-05-27") /// get_date(parsed, ["a", "b", "c"]) @@ -1706,6 +1706,7 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { /// A decoder that decodes TOML numbers into a `Number`. /// /// ## Examples +/// /// ```gleam /// let assert Ok(toml) = tom.parse_dynamic("lucy = 1337") /// decode.run(toml, decode.dict(decode.string, tom.number_decoder()))) From 26ca90c85bc72ec1ccd61194fce618fa0b43fa7d Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:42:55 -0500 Subject: [PATCH 09/25] Fix type for Month decoder --- src/tom.gleam | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tom.gleam b/src/tom.gleam index a674859..853a44c 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1863,7 +1863,7 @@ fn month_decoder() -> Decoder(calendar.Month) { "October" -> decode.success(calendar.October) "November" -> decode.success(calendar.November) "December" -> decode.success(calendar.December) - _ -> decode.failure(calendar.January, "month string") + _ -> decode.failure(calendar.January, "Month") } } From 634d5afadd7fa711027a408eb4a979dd244c96b7 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:46:48 -0500 Subject: [PATCH 10/25] Use two run steps for action --- .github/workflows/test.yml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e29c545..04491f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,20 +8,8 @@ on: pull_request: jobs: - test-erlang: - name: Run Tests (Erlang) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: erlef/setup-beam@v1 - with: - otp-version: "28" - gleam-version: "1.13.0" - rebar3-version: "3" - - run: gleam test --target erlang - - test-javascript: - name: Run Tests (JavaScript) + test: + name: Run Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -33,6 +21,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 + - run: gleam test --target erlang - run: gleam test --target javascript check-format: From 1474dfd64c94a4d170fd622c1edf1b571de4043f Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:49:30 -0500 Subject: [PATCH 11/25] Use parse_to_dynamic in comments --- src/tom.gleam | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 853a44c..33cddfd 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1708,7 +1708,7 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { /// ## Examples /// /// ```gleam -/// let assert Ok(toml) = tom.parse_dynamic("lucy = 1337") +/// let assert Ok(toml) = tom.parse_to_dynamic("lucy = 1337") /// decode.run(toml, decode.dict(decode.string, tom.number_decoder()))) /// // -> Ok(dict.from_list([#("lucy", tom.NumberInt(1337))])) /// ``` @@ -1729,7 +1729,7 @@ pub fn number_decoder() -> Decoder(Number) { /// ## Examples /// /// ```gleam -/// let assert Ok(toml) = tom.parse_dynamic("future = 2015-10-21") +/// let assert Ok(toml) = tom.parse_to_dynamic("future = 2015-10-21") /// decode.run(toml, decode.dict(decode.string, tom.date_decoder()))) /// // -> Ok( /// // dict.from_list([ @@ -1753,7 +1753,7 @@ pub fn date_decoder() -> Decoder(calendar.Date) { /// ## Examples /// /// ```gleam -/// let assert Ok(toml) = tom.parse_dynamic("time = 07:28:00") +/// let assert Ok(toml) = tom.parse_to_dynamic("time = 07:28:00") /// decode.run(toml, decode.dict(decode.string, tom.time_decoder()))) /// // -> Ok( /// // dict.from_list([ @@ -1779,7 +1779,7 @@ pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { /// ## Examples /// /// ```gleam -/// let assert Ok(toml) = tom.parse_dynamic("datetime = 2015-10-21T07:28:00") +/// let assert Ok(toml) = tom.parse_to_dynamic("datetime = 2015-10-21T07:28:00") /// decode.run(toml, decode.dict(decode.string, tom.datetime_decoder())) /// // -> Ok( /// // dict.from_list([ From f348a91e2dca124e643cdde951f8bdb1d6c5d352 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:52:06 -0500 Subject: [PATCH 12/25] Number docs --- src/tom.gleam | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 33cddfd..f32f2a8 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -86,7 +86,7 @@ type Tokens = type Parsed(a) = Result(#(a, Tokens), ParseError) -/// A number of any kind, returned by the `get_number`/`decode_number` functions. +/// A number of any kind, returned by the `get_number`/`number_decoder` functions. pub type Number { NumberInt(Int) NumberFloat(Float) @@ -1703,7 +1703,10 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { } } -/// A decoder that decodes TOML numbers into a `Number`. +/// A decoder that decodes TOML numbers into a `Number`. This should be used if +/// you wish to parse numeric values loosely, regardless of their underlying +/// type (e.g. to accept both floats and ints, or to check for NaN). +/// /// /// ## Examples /// From d4c5f85d491ecd9efd6a464ab4d9c74582c4199e Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:53:57 -0500 Subject: [PATCH 13/25] One more Number docs tweak --- src/tom.gleam | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index f32f2a8..33561b9 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1703,10 +1703,8 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { } } -/// A decoder that decodes TOML numbers into a `Number`. This should be used if -/// you wish to parse numeric values loosely, regardless of their underlying -/// type (e.g. to accept both floats and ints, or to check for NaN). -/// +/// A decoder that decodes TOML numbers into a `Number`. This could be an int, +/// a float, a NaN, or an infinity. /// /// ## Examples /// From cf892686e269d53b8b6b778987efb89384e05832 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 11:05:03 -0500 Subject: [PATCH 14/25] Add changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ae2f6..05bf808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Added parsing of TOML documents to `Dynamic`. + ## v2.0.2 - 2026-03-26 - Fixed a bug where multi-line strings containing `#` could parse incorrectly. From 8d5a2f52cbe349517a08864e586f8aaa0070ab66 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 11:32:35 -0500 Subject: [PATCH 15/25] Change the Duration Dynamic reporsentation to be consistent --- src/tom.gleam | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 33561b9..2eb928c 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1884,19 +1884,14 @@ fn offset_decoder() -> Decoder(Offset) { } fn duration_decoder() -> Decoder(duration.Duration) { - use raw_duration <- decode.then(decode.list(decode.int)) - - case raw_duration { - [seconds, nanos] -> { - let seconds_duration = duration.seconds(seconds) - let nanos_duration = duration.nanoseconds(nanos) - let full_duration = duration.add(seconds_duration, nanos_duration) + use seconds <- decode.field("seconds", decode.int) + use nanos <- decode.field("nanoseconds", decode.int) - decode.success(full_duration) - } + let seconds_duration = duration.seconds(seconds) + let nanos_duration = duration.nanoseconds(nanos) + let full_duration = duration.add(seconds_duration, nanos_duration) - _ -> decode.failure(duration.seconds(0), "Duration") - } + decode.success(full_duration) } @external(erlang, "tom_ffi", "nan_to_dynamic") @@ -1978,5 +1973,8 @@ fn offset_to_dynamic(offset: Offset) -> Dynamic { fn duration_to_dynamic(duration: duration.Duration) -> Dynamic { let #(seconds, nanos) = duration.to_seconds_and_nanoseconds(duration) - dynamic.array([dynamic.int(seconds), dynamic.int(nanos)]) + dynamic.properties([ + #(dynamic.string("seconds"), dynamic.int(seconds)), + #(dynamic.string("nanoseconds"), dynamic.int(nanos)), + ]) } From da2dab6f8f9ca4f553d964c3a95b17f9cd00dbb4 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Mon, 9 Feb 2026 08:25:19 -0500 Subject: [PATCH 16/25] Unsplit workflow --- .github/workflows/test.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04491f1..66825f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,15 +23,4 @@ jobs: node-version: 20 - run: gleam test --target erlang - run: gleam test --target javascript - - check-format: - name: Check Format - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: erlef/setup-beam@v1 - with: - otp-version: "28" - gleam-version: "1.14.0" - rebar3-version: "3" - run: gleam format --check src test From ea1027cfc9d07e21d9024498c6d2709f51f5695d Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Mon, 9 Feb 2026 09:02:30 -0500 Subject: [PATCH 17/25] Timestamp decoder, infinity/nan using sign --- src/tom.gleam | 87 ++++++++++++++++++++++++--------------------- src/tom_ffi.erl | 20 +++++------ src/tom_ffi.mjs | 24 +++++-------- test/tom_test.gleam | 79 ++++++++++++++++++++++++---------------- 4 files changed, 113 insertions(+), 97 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 2eb928c..393acde 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -67,11 +67,6 @@ pub type Sign { Negative } -/// A date time value, decoded by the `decode_datetime` function -pub type DateTimeValue { - DateTimeValue(date: calendar.Date, time: calendar.TimeOfDay, offset: Offset) -} - /// An error that can occur when parsing a TOML document. pub type ParseError { /// An unexpected character was encountered when parsing the document. @@ -103,16 +98,6 @@ pub type GetError { WrongType(key: List(String), expected: String, got: String) } -@internal -pub type NanValue { - NanValue(sign: Sign) -} - -@internal -pub type InfinityValue { - InfinityValue(sign: Sign) -} - // TODO: test /// Get a value of any type from a TOML document dictionary. /// @@ -1715,10 +1700,8 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { /// ``` pub fn number_decoder() -> Decoder(Number) { decode.one_of(decode.map(decode.int, NumberInt), or: [ - decode.map(nan_decoder(), fn(nan) { NumberNan(nan.sign) }), - decode.map(infinity_decoder(), fn(infinity) { - NumberInfinity(infinity.sign) - }), + decode.map(nan_decoder(), fn(sign) { NumberNan(sign) }), + decode.map(infinity_decoder(), fn(sign) { NumberInfinity(sign) }), // This must come _after_ the infinity decoder, as the stdlib implementation // will attempt to decode Infinity as a float decode.map(decode.float, NumberFloat), @@ -1774,35 +1757,59 @@ pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { decode.success(calendar.TimeOfDay(hours:, minutes:, seconds:, nanoseconds:)) } -/// A decoder that decodes TOML datetime into corresponding date, time, and +/// A decoder that decodes TOML date time into corresponding date, time, and /// offset parts. /// /// ## Examples /// /// ```gleam /// let assert Ok(toml) = tom.parse_to_dynamic("datetime = 2015-10-21T07:28:00") -/// decode.run(toml, decode.dict(decode.string, tom.datetime_decoder())) +/// decode.run(toml, decode.dict(decode.string, tom.calendar_date_time_decoder())) /// // -> Ok( /// // dict.from_list([ -/// // #("datetime", tom.DateTimeValue( -/// // date: calendar.Date( -/// // year: 2015, month: calendar.October, day: 21 -/// // ), -/// // time: calendar.TimeOfDay( -/// // hours: 7, minutes: 28, seconds: 0, nanoseconds: 0 -/// // ), -/// // offset: tom.Local +/// // #("datetime", #( +/// // calendar.Date(year: 2015, month: calendar.October, day: 21), +/// // calendar.TimeOfDay(hours: 7, minutes: 28, seconds: 0, nanoseconds: 0), +/// // tom.Local /// // )) /// // ]) /// // ) /// ``` /// -pub fn datetime_decoder() -> Decoder(DateTimeValue) { +pub fn calendar_date_time_decoder() -> Decoder( + #(calendar.Date, calendar.TimeOfDay, Offset), +) { use date <- decode.field("date", date_decoder()) use time <- decode.field("time", time_decoder()) use offset <- decode.field("offset", offset_decoder()) - decode.success(DateTimeValue(date:, time:, offset:)) + decode.success(#(date, time, offset)) +} + +/// A decoder that decodes TOML datetime into an unambiguous timestamp +/// +/// If a TOML date time has no offset it is ambiguous and cannot be converted +/// into a timestamp. There's no way to know what actual point in time it would +/// be as it would be different in different time zones. +/// +/// ## Examples +/// +/// ```gleam +/// let assert Ok(toml) = tom.parse_to_dynamic("datetime = 2015-10-21T14:28:00Z") +/// decode.run(toml, decode.dict(decode.string, tom.timestamp_decoder())) +/// // -> Ok(dict.from_list([#("datetime", timestamp.from_unix_seconds(1445437680))])) +/// ``` +/// +pub fn timestamp_decoder() -> Decoder(timestamp.Timestamp) { + use calendar_date_time <- decode.then(calendar_date_time_decoder()) + + case calendar_date_time { + #(date, time, Offset(offset)) -> + decode.success(timestamp.from_calendar(date, time, offset)) + + #(_date, _time, Local) -> + decode.failure(timestamp.unix_epoch, "DateTime with offset") + } } fn value_to_dynamic(value: Toml) -> Dynamic { @@ -1811,8 +1818,8 @@ fn value_to_dynamic(value: Toml) -> Dynamic { Float(x) -> dynamic.float(x) Bool(x) -> dynamic.bool(x) String(x) -> dynamic.string(x) - Nan(sign) -> nan_to_dynamic(NanValue(sign)) - Infinity(sign) -> infinity_to_dynamic(InfinityValue(sign)) + Nan(sign) -> nan_to_dynamic(sign) + Infinity(sign) -> infinity_to_dynamic(sign) Date(x) -> date_to_dynamic(x) Time(x) -> time_to_dynamic(x) DateTime(date, time, offset) -> datetime_to_dynamic(date, time, offset) @@ -1840,11 +1847,11 @@ fn table_to_dynamic(toml: Dict(String, Toml)) -> Dynamic { |> dynamic.properties() } -fn nan_decoder() -> Decoder(NanValue) { +fn nan_decoder() -> Decoder(Sign) { decode.new_primitive_decoder("NanValue", nan_from_dynamic) } -fn infinity_decoder() -> Decoder(InfinityValue) { +fn infinity_decoder() -> Decoder(Sign) { decode.new_primitive_decoder("InfinityValue", infinity_from_dynamic) } @@ -1896,21 +1903,19 @@ fn duration_decoder() -> Decoder(duration.Duration) { @external(erlang, "tom_ffi", "nan_to_dynamic") @external(javascript, "./tom_ffi.mjs", "nan_to_dynamic") -fn nan_to_dynamic(nan: NanValue) -> Dynamic +fn nan_to_dynamic(sign: Sign) -> Dynamic @external(erlang, "tom_ffi", "nan_from_dynamic") @external(javascript, "./tom_ffi.mjs", "nan_from_dynamic") -fn nan_from_dynamic(dynamic: Dynamic) -> Result(NanValue, NanValue) +fn nan_from_dynamic(dynamic: Dynamic) -> Result(Sign, Sign) @external(erlang, "tom_ffi", "infinity_to_dynamic") @external(javascript, "./tom_ffi.mjs", "infinity_to_dynamic") -fn infinity_to_dynamic(infinity: InfinityValue) -> Dynamic +fn infinity_to_dynamic(sigh: Sign) -> Dynamic @external(erlang, "tom_ffi", "infinity_from_dynamic") @external(javascript, "./tom_ffi.mjs", "infinity_from_dynamic") -fn infinity_from_dynamic( - infinity: Dynamic, -) -> Result(InfinityValue, InfinityValue) +fn infinity_from_dynamic(infinity: Dynamic) -> Result(Sign, Sign) fn date_to_dynamic(date: calendar.Date) -> Dynamic { dynamic.properties([ diff --git a/src/tom_ffi.erl b/src/tom_ffi.erl index 57038ed..08feb2e 100644 --- a/src/tom_ffi.erl +++ b/src/tom_ffi.erl @@ -6,29 +6,29 @@ nan_from_dynamic/1 ]). -nan_to_dynamic({nan_value, positive}) -> +nan_to_dynamic(positive) -> positive_nan; -nan_to_dynamic({nan_value, negative}) -> +nan_to_dynamic(negative) -> negative_nan. nan_from_dynamic(positive_nan) -> - {ok, {nan_value, positive}}; + {ok, positive}; nan_from_dynamic(negative_nan) -> - {ok, {nan_value, negative}}; + {ok, negative}; nan_from_dynamic(_Other) -> % Value here is a placeholder - {error, {nan_value, positive}}. + {error, positive}. -infinity_to_dynamic({infinity_value, positive}) -> +infinity_to_dynamic(positive) -> positive_infinity; -infinity_to_dynamic({infinity_value, negative}) -> +infinity_to_dynamic(negative) -> negative_infinity. infinity_from_dynamic(positive_infinity) -> - {ok, {infinity_value, positive}}; + {ok, positive}; infinity_from_dynamic(negative_infinity) -> - {ok, {infinity_value, negative}}; + {ok, negative}; infinity_from_dynamic(_Other) -> % Value here is a placeholder - {error, {infinity_value, positive}}. + {error, positive}. diff --git a/src/tom_ffi.mjs b/src/tom_ffi.mjs index 25c024a..9dd6d40 100644 --- a/src/tom_ffi.mjs +++ b/src/tom_ffi.mjs @@ -4,19 +4,13 @@ import { Sign$Negative, Sign$isPositive, Sign$isNegative, - InfinityValue$InfinityValue$sign, - InfinityValue$InfinityValue, - NanValue$NanValue$sign, - NanValue$NanValue, } from "./tom.mjs"; // We can't represent positive/negative NaNs in JS so we must have a representation for them const negativeNaN = Symbol("-NaN"); const positiveNaN = Symbol("+NaN"); -export function nan_to_dynamic(nan_value) { - let sign = NanValue$NanValue$sign(nan_value); - +export function nan_to_dynamic(sign) { if (Sign$isPositive(sign)) { return positiveNaN; } else if (Sign$isNegative(sign)) { @@ -29,18 +23,16 @@ export function nan_to_dynamic(nan_value) { export function nan_from_dynamic(value) { if (value == positiveNaN) { - return Result$Ok(NanValue$NanValue(Sign$Positive())); + return Result$Ok(Sign$Positive()); } else if (value == negativeNaN) { - return Result$Ok(NanValue$NanValue(Sign$Negative())); + return Result$Ok(Sign$Negative()); } else { // Value here is a placeholder - return Result$Error(NanValue$NanValue(Sign$Positive())); + return Result$Error(Sign$Positive()); } } -export function infinity_to_dynamic(infinity_value) { - let sign = InfinityValue$InfinityValue$sign(infinity_value); - +export function infinity_to_dynamic(sign) { if (Sign$isPositive(sign)) { return Infinity; } else if (Sign$isNegative(sign)) { @@ -53,11 +45,11 @@ export function infinity_to_dynamic(infinity_value) { export function infinity_from_dynamic(value) { if (value == Infinity) { - return Result$Ok(InfinityValue$InfinityValue(Sign$Positive())); + return Result$Ok(Sign$Positive()); } else if (value == -Infinity) { - return Result$Ok(InfinityValue$InfinityValue(Sign$Negative())); + return Result$Ok(Sign$Negative()); } else { // Value here is a placeholder - return Result$Error(NanValue$NanValue(Sign$Positive())); + return Result$Error(Sign$Positive()); } } diff --git a/test/tom_test.gleam b/test/tom_test.gleam index 21acdb1..c679beb 100644 --- a/test/tom_test.gleam +++ b/test/tom_test.gleam @@ -1315,20 +1315,15 @@ pub fn to_dynamic_datetime_test() { let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00Z") let dynamic = tom.to_dynamic(parsed) let decoder = { - use a_field <- decode.field("a", tom.datetime_decoder()) + use a_field <- decode.field("a", tom.calendar_date_time_decoder()) decode.success(a_field) } assert decode.run(dynamic, decoder) - == Ok(tom.DateTimeValue( - date: calendar.Date(year: 1979, month: calendar.May, day: 27), - time: calendar.TimeOfDay( - hours: 7, - minutes: 32, - seconds: 0, - nanoseconds: 0, - ), - offset: tom.Offset(calendar.utc_offset), + == Ok(#( + calendar.Date(year: 1979, month: calendar.May, day: 27), + calendar.TimeOfDay(hours: 7, minutes: 32, seconds: 0, nanoseconds: 0), + tom.Offset(calendar.utc_offset), )) } @@ -1336,20 +1331,15 @@ pub fn to_dynamic_datetime_local_offset_test() { let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00") let dynamic = tom.to_dynamic(parsed) let decoder = { - use a_field <- decode.field("a", tom.datetime_decoder()) + use a_field <- decode.field("a", tom.calendar_date_time_decoder()) decode.success(a_field) } assert decode.run(dynamic, decoder) - == Ok(tom.DateTimeValue( - date: calendar.Date(year: 1979, month: calendar.May, day: 27), - time: calendar.TimeOfDay( - hours: 7, - minutes: 32, - seconds: 0, - nanoseconds: 0, - ), - offset: tom.Local, + == Ok(#( + calendar.Date(year: 1979, month: calendar.May, day: 27), + calendar.TimeOfDay(hours: 7, minutes: 32, seconds: 0, nanoseconds: 0), + tom.Local, )) } @@ -1357,19 +1347,48 @@ pub fn to_dynamic_datetime_numeric_offset_offset_test() { let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00-05:00") let dynamic = tom.to_dynamic(parsed) let decoder = { - use a_field <- decode.field("a", tom.datetime_decoder()) + use a_field <- decode.field("a", tom.calendar_date_time_decoder()) decode.success(a_field) } assert decode.run(dynamic, decoder) - == Ok(tom.DateTimeValue( - date: calendar.Date(year: 1979, month: calendar.May, day: 27), - time: calendar.TimeOfDay( - hours: 7, - minutes: 32, - seconds: 0, - nanoseconds: 0, - ), - offset: tom.Offset(duration.hours(-5)), + == Ok(#( + calendar.Date(year: 1979, month: calendar.May, day: 27), + calendar.TimeOfDay(hours: 7, minutes: 32, seconds: 0, nanoseconds: 0), + tom.Offset(duration.hours(-5)), )) } + +pub fn to_dynamic_timestamp_utc_test() { + let assert Ok(parsed) = tom.parse("a = 1970-01-01T00:00:00Z") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.timestamp_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) == Ok(timestamp.unix_epoch) +} + +pub fn to_dynamic_timestamp_with_offset_test() { + let assert Ok(parsed) = tom.parse("a = 1970-01-01T01:00:00+01:00") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.timestamp_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) == Ok(timestamp.unix_epoch) +} + +pub fn to_dynamic_timestamp_local_fails_test() { + let assert Ok(parsed) = tom.parse("a = 1970-01-01T00:00:00") + let dynamic = tom.to_dynamic(parsed) + let decoder = { + use a_field <- decode.field("a", tom.timestamp_decoder()) + decode.success(a_field) + } + + assert decode.run(dynamic, decoder) + == Error([decode.DecodeError("DateTime with offset", "Dict", ["a"])]) +} From 81a95c8780b72b459ed6aeff40d99dbc96323641 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Mon, 9 Feb 2026 09:06:53 -0500 Subject: [PATCH 18/25] Consistency in 'datetime' vs 'date time' --- src/tom.gleam | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 393acde..0ef545d 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1597,7 +1597,7 @@ pub fn as_timestamp(toml: Toml) -> Result(timestamp.Timestamp, GetError) { } } -/// Get a datetime from a TOML document. +/// Get a date time from a TOML document. /// /// ## Examples /// @@ -1786,7 +1786,7 @@ pub fn calendar_date_time_decoder() -> Decoder( decode.success(#(date, time, offset)) } -/// A decoder that decodes TOML datetime into an unambiguous timestamp +/// A decoder that decodes TOML date time into an unambiguous timestamp /// /// If a TOML date time has no offset it is ambiguous and cannot be converted /// into a timestamp. There's no way to know what actual point in time it would From 1e4d1d57ab524e26060a0f662bab2373eaa263d4 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sun, 15 Mar 2026 13:04:26 -0400 Subject: [PATCH 19/25] Formatting suggestions Co-authored-by: Louis Pilfold --- src/tom.gleam | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 0ef545d..f400b49 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1700,8 +1700,8 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { /// ``` pub fn number_decoder() -> Decoder(Number) { decode.one_of(decode.map(decode.int, NumberInt), or: [ - decode.map(nan_decoder(), fn(sign) { NumberNan(sign) }), - decode.map(infinity_decoder(), fn(sign) { NumberInfinity(sign) }), + decode.map(nan_decoder(), NumberNan), + decode.map(infinity_decoder(), NumberInfinity), // This must come _after_ the infinity decoder, as the stdlib implementation // will attempt to decode Infinity as a float decode.map(decode.float, NumberFloat), @@ -1716,12 +1716,12 @@ pub fn number_decoder() -> Decoder(Number) { /// let assert Ok(toml) = tom.parse_to_dynamic("future = 2015-10-21") /// decode.run(toml, decode.dict(decode.string, tom.date_decoder()))) /// // -> Ok( -/// // dict.from_list([ -/// // #("future", calendar.Date( -/// // year: 2015, month: calendar.October, day: 21 -/// // )), -/// // ]) -/// // ) +/// // dict.from_list([ +/// // #("future", calendar.Date( +/// // year: 2015, month: calendar.October, day: 21 +/// // )), +/// // ]) +/// // ) /// ``` /// pub fn date_decoder() -> Decoder(calendar.Date) { @@ -1740,12 +1740,12 @@ pub fn date_decoder() -> Decoder(calendar.Date) { /// let assert Ok(toml) = tom.parse_to_dynamic("time = 07:28:00") /// decode.run(toml, decode.dict(decode.string, tom.time_decoder()))) /// // -> Ok( -/// // dict.from_list([ -/// // #("time", calendar.TimeOfDay( -/// // hours: 7, minutes: 28, seconds: 0, nanoseconds: 0 -/// // )) -/// // ]) -/// // ) +/// // dict.from_list([ +/// // #("time", calendar.TimeOfDay( +/// // hours: 7, minutes: 28, seconds: 0, nanoseconds: 0 +/// // )) +/// // ]) +/// // ) /// ``` /// pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { From fd4102e2a8251be889e2cc2ad24f7d06e88e296d Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sun, 15 Mar 2026 13:01:26 -0400 Subject: [PATCH 20/25] Review comments --- src/tom.gleam | 12 ++++++------ test/tom_test.gleam | 41 ++++++++--------------------------------- 2 files changed, 14 insertions(+), 39 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index f400b49..a4b059b 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1738,7 +1738,7 @@ pub fn date_decoder() -> Decoder(calendar.Date) { /// /// ```gleam /// let assert Ok(toml) = tom.parse_to_dynamic("time = 07:28:00") -/// decode.run(toml, decode.dict(decode.string, tom.time_decoder()))) +/// decode.run(toml, decode.dict(decode.string, tom.time_of_day_decoder()))) /// // -> Ok( /// // dict.from_list([ /// // #("time", calendar.TimeOfDay( @@ -1748,7 +1748,7 @@ pub fn date_decoder() -> Decoder(calendar.Date) { /// // ) /// ``` /// -pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { +pub fn time_of_day_decoder() -> Decoder(calendar.TimeOfDay) { use hours <- decode.field("hours", decode.int) use minutes <- decode.field("minutes", decode.int) use seconds <- decode.field("seconds", decode.int) @@ -1764,7 +1764,7 @@ pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { /// /// ```gleam /// let assert Ok(toml) = tom.parse_to_dynamic("datetime = 2015-10-21T07:28:00") -/// decode.run(toml, decode.dict(decode.string, tom.calendar_date_time_decoder())) +/// decode.run(toml, decode.dict(decode.string, tom.calendar_date_time_of_day_decoder())) /// // -> Ok( /// // dict.from_list([ /// // #("datetime", #( @@ -1776,11 +1776,11 @@ pub fn time_decoder() -> Decoder(calendar.TimeOfDay) { /// // ) /// ``` /// -pub fn calendar_date_time_decoder() -> Decoder( +pub fn calendar_date_time_of_day_decoder() -> Decoder( #(calendar.Date, calendar.TimeOfDay, Offset), ) { use date <- decode.field("date", date_decoder()) - use time <- decode.field("time", time_decoder()) + use time <- decode.field("time", time_of_day_decoder()) use offset <- decode.field("offset", offset_decoder()) decode.success(#(date, time, offset)) @@ -1801,7 +1801,7 @@ pub fn calendar_date_time_decoder() -> Decoder( /// ``` /// pub fn timestamp_decoder() -> Decoder(timestamp.Timestamp) { - use calendar_date_time <- decode.then(calendar_date_time_decoder()) + use calendar_date_time <- decode.then(calendar_date_time_of_day_decoder()) case calendar_date_time { #(date, time, Offset(offset)) -> diff --git a/test/tom_test.gleam b/test/tom_test.gleam index c679beb..0a24adb 100644 --- a/test/tom_test.gleam +++ b/test/tom_test.gleam @@ -4,7 +4,6 @@ import gleam/int import gleam/list import gleam/order import gleam/result -import gleam/string import gleam/time/calendar import gleam/time/duration import gleam/time/timestamp @@ -1193,21 +1192,9 @@ pub fn to_dynamic_table_test() { decode.success(a_table_field) } - let decoded = - dynamic - |> decode.run(decoder) - |> result.map(fn(value) { - value - |> dict.to_list() - |> list.sort(fn(a, b) { - let #(a_1, a_2) = a - let #(b_1, b_2) = b - - order.break_tie(string.compare(a_1, b_1), int.compare(a_2, b_2)) - }) - }) + let decoded = decode.run(dynamic, decoder) - assert decoded == Ok([#("a", 1), #("b", 2), #("c", 3)]) + assert decoded == Ok(dict.from_list([#("a", 1), #("b", 2), #("c", 3)])) } pub fn to_dynamic_inline_table_test() { @@ -1222,21 +1209,9 @@ pub fn to_dynamic_inline_table_test() { decode.success(a_table_field) } - let decoded = - dynamic - |> decode.run(decoder) - |> result.map(fn(value) { - value - |> dict.to_list() - |> list.sort(fn(a, b) { - let #(a_1, a_2) = a - let #(b_1, b_2) = b - - order.break_tie(string.compare(a_1, b_1), int.compare(a_2, b_2)) - }) - }) + let decoded = decode.run(dynamic, decoder) - assert decoded == Ok([#("a", 1), #("b", 2), #("c", 3)]) + assert decoded == Ok(dict.from_list([#("a", 1), #("b", 2), #("c", 3)])) } pub fn to_dynamic_array_of_tables_test() { @@ -1303,7 +1278,7 @@ pub fn to_dynamic_time_test() { let assert Ok(parsed) = tom.parse("a = 07:32:01") let dynamic = tom.to_dynamic(parsed) let decoder = { - use a_field <- decode.field("a", tom.time_decoder()) + use a_field <- decode.field("a", tom.time_of_day_decoder()) decode.success(a_field) } @@ -1315,7 +1290,7 @@ pub fn to_dynamic_datetime_test() { let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00Z") let dynamic = tom.to_dynamic(parsed) let decoder = { - use a_field <- decode.field("a", tom.calendar_date_time_decoder()) + use a_field <- decode.field("a", tom.calendar_date_time_of_day_decoder()) decode.success(a_field) } @@ -1331,7 +1306,7 @@ pub fn to_dynamic_datetime_local_offset_test() { let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00") let dynamic = tom.to_dynamic(parsed) let decoder = { - use a_field <- decode.field("a", tom.calendar_date_time_decoder()) + use a_field <- decode.field("a", tom.calendar_date_time_of_day_decoder()) decode.success(a_field) } @@ -1347,7 +1322,7 @@ pub fn to_dynamic_datetime_numeric_offset_offset_test() { let assert Ok(parsed) = tom.parse("a = 1979-05-27T07:32:00-05:00") let dynamic = tom.to_dynamic(parsed) let decoder = { - use a_field <- decode.field("a", tom.calendar_date_time_decoder()) + use a_field <- decode.field("a", tom.calendar_date_time_of_day_decoder()) decode.success(a_field) } From bb204dfb8406263cc6ba7ce8c5528d2dba4d74dc Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sun, 15 Mar 2026 13:06:17 -0400 Subject: [PATCH 21/25] Newline after docblocks --- src/tom.gleam | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tom.gleam b/src/tom.gleam index a4b059b..c89ca4e 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1473,6 +1473,7 @@ fn parse_offset_hours(input: Tokens, sign: Sign) -> Parsed(Offset) { /// as_int(Float(1.4)) /// // -> Error(WrongType([], "Int", "Float")) /// ``` +/// pub fn as_int(toml: Toml) -> Result(Int, GetError) { case toml { Int(f) -> Ok(f) @@ -1493,6 +1494,7 @@ pub fn as_int(toml: Toml) -> Result(Int, GetError) { /// as_float(Int(1)) /// // -> Error(WrongType([], "Float", "Int")) /// ``` +/// pub fn as_float(toml: Toml) -> Result(Float, GetError) { case toml { Float(f) -> Ok(f) @@ -1513,6 +1515,7 @@ pub fn as_float(toml: Toml) -> Result(Float, GetError) { /// as_bool(Int(1)) /// // -> Error(WrongType([], "Bool", "Int")) /// ``` +/// pub fn as_bool(toml: Toml) -> Result(Bool, GetError) { case toml { Bool(b) -> Ok(b) @@ -1533,6 +1536,7 @@ pub fn as_bool(toml: Toml) -> Result(Bool, GetError) { /// as_string(Int(1)) /// // -> Error(WrongType([], "String", "Int")) /// ``` +/// pub fn as_string(toml: Toml) -> Result(String, GetError) { case toml { String(s) -> Ok(s) @@ -1553,6 +1557,7 @@ pub fn as_string(toml: Toml) -> Result(String, GetError) { /// as_date(Int(1)) /// // -> Error(WrongType([], "Date", "Int")) /// ``` +/// pub fn as_date(toml: Toml) -> Result(calendar.Date, GetError) { case toml { Date(d) -> Ok(d) @@ -1573,6 +1578,7 @@ pub fn as_date(toml: Toml) -> Result(calendar.Date, GetError) { /// as_time_of_day(Int(1)) /// // -> Error(WrongType([], "Time", "Int")) /// ``` +/// pub fn as_time_of_day(toml: Toml) -> Result(calendar.TimeOfDay, GetError) { case toml { Time(t) -> Ok(t) @@ -1588,6 +1594,7 @@ pub fn as_time_of_day(toml: Toml) -> Result(calendar.TimeOfDay, GetError) { /// as_timestamp(Int(1)) /// // -> Error(WrongType([], "DateTime with offset", "Int")) /// ``` +/// pub fn as_timestamp(toml: Toml) -> Result(timestamp.Timestamp, GetError) { case toml { DateTime(date:, time:, offset: Offset(offset)) -> @@ -1610,6 +1617,7 @@ pub fn as_timestamp(toml: Toml) -> Result(timestamp.Timestamp, GetError) { /// as_calendar_time(Int(1)) /// // -> Error(WrongType([], "DateTime", "Int")) /// ``` +/// pub fn as_calendar_time( toml: Toml, ) -> Result(#(calendar.Date, calendar.TimeOfDay, Offset), GetError) { @@ -1632,6 +1640,7 @@ pub fn as_calendar_time( /// as_array(Int(1)) /// // -> Error(WrongType([], "Array", "Int")) /// ``` +/// pub fn as_array(toml: Toml) -> Result(List(Toml), GetError) { case toml { Array(arr) -> Ok(arr) @@ -1652,6 +1661,7 @@ pub fn as_array(toml: Toml) -> Result(List(Toml), GetError) { /// as_table(Int(1)) /// // -> Error(WrongType([], "Table", "Int")) /// ``` +/// pub fn as_table(toml: Toml) -> Result(Dict(String, Toml), GetError) { case toml { Table(tbl) -> Ok(tbl) @@ -1678,6 +1688,7 @@ pub fn as_table(toml: Toml) -> Result(Dict(String, Toml), GetError) { /// as_number(Bool(true)) /// // -> Error(WrongType([], "Number", "Bool")) /// ``` +/// pub fn as_number(toml: Toml) -> Result(Number, GetError) { case toml { Int(x) -> Ok(NumberInt(x)) @@ -1698,6 +1709,7 @@ pub fn as_number(toml: Toml) -> Result(Number, GetError) { /// decode.run(toml, decode.dict(decode.string, tom.number_decoder()))) /// // -> Ok(dict.from_list([#("lucy", tom.NumberInt(1337))])) /// ``` +/// pub fn number_decoder() -> Decoder(Number) { decode.one_of(decode.map(decode.int, NumberInt), or: [ decode.map(nan_decoder(), NumberNan), From f9306a155da0b86fcefdcbe7d64cb8b66e9321b3 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sun, 15 Mar 2026 13:07:11 -0400 Subject: [PATCH 22/25] Missing formatting suggestion for dockblock Co-authored-by: Louis Pilfold --- src/tom.gleam | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index c89ca4e..dc2bf26 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1778,14 +1778,14 @@ pub fn time_of_day_decoder() -> Decoder(calendar.TimeOfDay) { /// let assert Ok(toml) = tom.parse_to_dynamic("datetime = 2015-10-21T07:28:00") /// decode.run(toml, decode.dict(decode.string, tom.calendar_date_time_of_day_decoder())) /// // -> Ok( -/// // dict.from_list([ -/// // #("datetime", #( -/// // calendar.Date(year: 2015, month: calendar.October, day: 21), -/// // calendar.TimeOfDay(hours: 7, minutes: 28, seconds: 0, nanoseconds: 0), -/// // tom.Local -/// // )) -/// // ]) -/// // ) +/// // dict.from_list([ +/// // #("datetime", #( +/// // calendar.Date(2015, calendar.October, 21), +/// // calendar.TimeOfDay(7, 28, 0, 0), +/// // tom.Local +/// // )) +/// // ]) +/// // ) /// ``` /// pub fn calendar_date_time_of_day_decoder() -> Decoder( From 95e1ca271f0ed5cd4270712fcf6d0206aefe793e Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sun, 15 Mar 2026 13:08:27 -0400 Subject: [PATCH 23/25] Blanklines --- src/tom.gleam | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tom.gleam b/src/tom.gleam index dc2bf26..c6f6576 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -148,6 +148,7 @@ pub fn get( /// decode.run(dynamic, decoder) /// // -> Ok(#("Lucy", 5)) /// ``` +/// pub fn to_dynamic(toml: Dict(String, Toml)) -> Dynamic { table_to_dynamic(toml) } @@ -173,6 +174,7 @@ pub fn to_dynamic(toml: Dict(String, Toml)) -> Dynamic { /// decode.run(dynamic, decoder) /// // -> Ok(#("Lucy", 5)) /// ``` +/// pub fn parse_to_dynamic(input: String) -> Result(Dynamic, ParseError) { input |> parse() From 493bf8cf146ab58625b3f2212efa23ccfa2facfa Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sun, 15 Mar 2026 13:10:55 -0400 Subject: [PATCH 24/25] Remove unneeded sorting --- test/tom_test.gleam | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/test/tom_test.gleam b/test/tom_test.gleam index 0a24adb..b7283ec 100644 --- a/test/tom_test.gleam +++ b/test/tom_test.gleam @@ -1,8 +1,5 @@ import gleam/dict import gleam/dynamic/decode -import gleam/int -import gleam/list -import gleam/order import gleam/result import gleam/time/calendar import gleam/time/duration @@ -1246,19 +1243,8 @@ pub fn to_dynamic_array_of_tables_test() { decode.success(a_field) } - let decoded = - dynamic - |> decode.run(decode) - |> result.map(fn(value) { - list.sort(value, fn(a, b) { - let #(a_1, a_2, a_3) = a - let #(b_1, b_2, b_3) = b - - int.compare(a_1, b_1) - |> order.break_tie(int.compare(a_2, b_2)) - |> order.break_tie(int.compare(a_3, b_3)) - }) - }) + let decoded = decode.run(dynamic, decode) + assert decoded == Ok([#(1, 2, 3), #(4, 5, 6), #(7, 8, 9)]) } From 0d7c0f60eb6ccd9418492a7a4f31893144649838 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sun, 15 Mar 2026 22:26:02 -0400 Subject: [PATCH 25/25] Compare to dicts directly in dynamic nested table test --- test/tom_test.gleam | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/tom_test.gleam b/test/tom_test.gleam index b7283ec..b856649 100644 --- a/test/tom_test.gleam +++ b/test/tom_test.gleam @@ -1230,22 +1230,21 @@ pub fn to_dynamic_array_of_tables_test() { ) let dynamic = tom.to_dynamic(parsed) - let decode_table = { - use a_field <- decode.field("a", decode.int) - use b_field <- decode.field("b", decode.int) - use c_field <- decode.field("c", decode.int) - - decode.success(#(a_field, b_field, c_field)) - } let decode = { + let decode_table = decode.dict(decode.string, decode.int) use a_field <- decode.field("a", decode.list(decode_table)) decode.success(a_field) } let decoded = decode.run(dynamic, decode) - assert decoded == Ok([#(1, 2, 3), #(4, 5, 6), #(7, 8, 9)]) + assert decoded + == Ok([ + dict.from_list([#("a", 1), #("b", 2), #("c", 3)]), + dict.from_list([#("a", 4), #("b", 5), #("c", 6)]), + dict.from_list([#("a", 7), #("b", 8), #("c", 9)]), + ]) } pub fn to_dynamic_date_test() {