From 0ec887cc26d99aa1c6f1460e020761a7124123ef Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 29 Nov 2025 21:20:05 -0500 Subject: [PATCH 01/15] 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 448ca5e..a557aac 100644 --- a/gleam.toml +++ b/gleam.toml @@ -7,7 +7,7 @@ repository = { type = "github", user = "lpil", repo = "tom" } links = [{ title = "TOML website", href = "https://toml.io/en/" }] [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 ecb73f0..e038a50 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. /// @@ -1597,3 +1626,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 47d6b95..189e7ab 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 @@ -1163,3 +1168,286 @@ pub fn get_timestamp_test() { tom.get_timestamp(parsed, ["a", "b", "c"]) |> should.equal(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 6b5b7a17364953180515b35d4d456d3345d09856 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 29 Nov 2025 21:28:36 -0500 Subject: [PATCH 02/15] Add JS target to tests --- .github/workflows/test.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b82b75f..7371a94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,14 +9,32 @@ 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.10.0" + gleam-version: "1.13.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 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.13.0" rebar3-version: "3" - # elixir-version: "1.15.4" - - run: gleam test - run: gleam format --check src test From e64b0859e8028ad4dada7d21eefed99aca966be2 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:16:59 -0500 Subject: [PATCH 03/15] 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 0fee6cb..9adb623 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 e038a50..2657a5b 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) @@ -1636,122 +1637,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 { @@ -1798,11 +1772,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") @@ -1831,16 +1848,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([ @@ -1863,11 +1886,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 ae9758cf54d00958e72b858dd0812db3a17794e1 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:20:13 -0500 Subject: [PATCH 04/15] 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 7371a94..1760fd2 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.13.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 f85df9de796bd9ecd99b5c6519923871a1d818a4 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:31:33 -0500 Subject: [PATCH 05/15] 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 2657a5b..6d50e61 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 4371c34dc02dc1755e48c7667d86c95d4305994d Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:32:41 -0500 Subject: [PATCH 06/15] 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 6d50e61..2ec3365 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1681,8 +1681,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 cba0e4bb819cf45ad3073f6e1214c480ce733295 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:37:18 -0500 Subject: [PATCH 07/15] 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 189e7ab..af0f8ed 100644 --- a/test/tom_test.gleam +++ b/test/tom_test.gleam @@ -1451,3 +1451,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 8ab3f552c2cd71c8d4c1e0e9b0d21b997d0c072b Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:41:40 -0500 Subject: [PATCH 08/15] Heading newlines --- src/tom.gleam | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 2ec3365..4e61a53 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"]) @@ -1670,6 +1670,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 05b6aed4e3aa973307e6cd37baa36413867295bd Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:42:55 -0500 Subject: [PATCH 09/15] 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 4e61a53..a2747f6 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1827,7 +1827,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 c8b500291f676f20e63b2cdb9f68cd9e66e0460c Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:46:48 -0500 Subject: [PATCH 10/15] 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 1760fd2..b6238ec 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 c497a8b24f38f670676854439f839b91b95ae139 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:49:30 -0500 Subject: [PATCH 11/15] 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 a2747f6..8d0596b 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1672,7 +1672,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))])) /// ``` @@ -1693,7 +1693,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([ @@ -1717,7 +1717,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([ @@ -1743,7 +1743,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 4b6af66409400e8e68f108b3e26792b9253211b1 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:52:06 -0500 Subject: [PATCH 12/15] Number docs --- src/tom.gleam | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tom.gleam b/src/tom.gleam index 8d0596b..93a0397 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) @@ -1667,7 +1667,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 5c2405cb7965d6130223b23ff2e7c162ae535668 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 10:53:57 -0500 Subject: [PATCH 13/15] 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 93a0397..4ce6e65 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1667,10 +1667,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 4ff38e760226a60c9ac3aaf289a81c415e896f39 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 11:05:03 -0500 Subject: [PATCH 14/15] Add changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0798736..e2001c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Added parsing of TOML documents to `Dynamic`. + ## v2.0.0 - 2025-05-31 - Added [gleam_time](https://hexdocs.pm/gleam_time/index.html) as a dependency. From 7cef68a26ff03fef7eb1ba73fde14e3b49e25f32 Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Sat, 10 Jan 2026 11:32:35 -0500 Subject: [PATCH 15/15] 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 4ce6e65..ac2c6fc 100644 --- a/src/tom.gleam +++ b/src/tom.gleam @@ -1848,19 +1848,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") @@ -1942,5 +1937,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)), + ]) }