gren-toml
A TOML 1.1 parser for Gren. You can use it to read TOML files easily.
You can also create TOML files. And, it tracks all positions of tokens,
strings, etc, within the file, including those of comments. Your program
can update TOML files and re-write them accurately, without losing the
author's original comments. The blank lines are where you left them,
the comments are attached to the keys they were written for, and the
1.50 you wrote is still 1.50 and not 1.5.
Parse and re-write:
Toml.parseBytes source
|> Result.map Toml.toString
--> Ok (the same bytes back)
That round trip is exact for every one of the 220 valid files in the official TOML test suite. The AST stores the whitespace and the comments as text so that it can re-write correctly.
Three complete examples
These are examples of using gren-toml in Gren node programs, each
asking more of the TOML file than the last. Everything below compiles
as written. The first reads a single value. The second reads the whole
file into a type of your own. The third owns the file: it creates it on
the first run, reads it on every run after, and writes it back without
disturbing anything the user did to it.
Reading one value
You know the section and you know the key, and you want the one value. There is nothing to define first.
# Where the service listens.
[server]
host = "0.0.0.0"
port = 8080
import Toml.Decode as Decode
Decode.fromBytes (Decode.at [ "server", "host" ] Decode.string) bytes
--> Ok "0.0.0.0"
Decode.fromBytes (Decode.at [ "server", "port" ] Decode.int) bytes
--> Ok 8080
With the file read off the disk around it, that is the whole program:
import FileSystem
import FileSystem.Path exposing (Path)
import Task exposing (Task)
readHost : FileSystem.Permission -> Path -> Task String String
readHost fs path =
FileSystem.readFile fs path
|> Task.mapError FileSystem.errorToString
|> Task.andThen
(\bytes ->
when Decode.fromBytes (Decode.at [ "server", "host" ] Decode.string) bytes is
Ok host ->
Task.succeed host
Err problem ->
Task.fail (Decode.errorToString problem)
)
-
atsays where, the decoder says what.[ "server", "host" ]is a path through the tables, and it finds the value whether the file wrote a[server]header orserver.host = "0.0.0.0"on one line. For a key at the top level of the file,Decode.field "name"is the same thing with one step. -
The decoder is the type you expect:
string,bool,int,float,bigInt,bigDecimal, and one for each of TOML's four date and time shapes. Nothing has to be checked afterwards -- a value of the wrong type is anErrhere. -
fromBytesdoes all of it in one call: parse, check what the document means, walk the path, read the value. There is no document to hold on to. -
The error names the path, so it is worth printing as it comes:
at server.host: expected an integer, found a string at server.nope: no such key -
bigIntfor a number that may not fit. TOML integers have no width, sointrefuses a number too large for a GrenIntrather than truncating it, and the error says what to reach for:9223372036854775807 does not fit in an Int; use bigInt to read it exactly. -
More than a value or two and this stops paying. Each one is another parse of the file, and nothing anywhere says what the configuration as a whole is. That is the next example.
Reading a config file into your own type
The same file, with a second section in it:
# Where the service listens.
[server]
host = "0.0.0.0"
port = 8080
[logging]
level = "info" # one of: debug, info, warn
# Optional. Logs go to stdout when this is missing.
file = "/var/log/svc.log"
The type you want it to become, and a decoder that says how the file maps onto it:
import Toml.Decode as Decode exposing (Decoder)
type alias Config =
{ host : String
, listenPort : Int
, level : String
, logFile : Maybe String
}
config : Decoder Config
config =
Decode.succeed
(\host listenPort level logFile ->
{ host = host, listenPort = listenPort, level = level, logFile = logFile }
)
|> Decode.andMap (Decode.at [ "server", "host" ] Decode.string)
|> Decode.andMap (Decode.at [ "server", "port" ] Decode.int)
|> Decode.andMap (Decode.at [ "logging", "level" ] Decode.string)
|> Decode.andMap (Decode.field "logging" (Decode.optionalField "file" Decode.string))
Line by line:
Decode.succeedstarts with a function that builds the record, and eachandMapfeeds it one decoded value, in the order the arguments are written. To add a field to the config, add an argument and a line.map2andmap3exist for the short cases.Decode.at [ "server", "host" ]is a path through the table tree. It finds the value whether the file wrote a[server]header withhostunder it, orserver.host = "0.0.0.0"on one line. Your decoder does not know or care how the file was laid out.Decode.intis where the type is checked. A value that is not an integer fails right here, and the error names the path. Nothing downstream needs to check again.optionalFieldisNothingwhen the key is absent and still an error when the key is present with the wrong type. That is the difference frommaybe, which forgives both.
Reading the file is one function:
import FileSystem
import FileSystem.Path exposing (Path)
import Task exposing (Task)
loadConfig : FileSystem.Permission -> Path -> Task String Config
loadConfig fs path =
FileSystem.readFile fs path
|> Task.mapError FileSystem.errorToString
|> Task.andThen
(\bytes ->
when Decode.fromBytes config bytes is
Ok found ->
Task.succeed found
Err problem ->
Task.fail (Decode.errorToString problem)
)
-
FileSystem.readFilehands youBytes. Hand the bytes on.Decode.fromBytesparses, checks what the document means (duplicate keys, a table defined twice) and decodes, in one call. It takes bytes rather than a string because a UTF-8 error can only be seen before the text has been repaired -- see start from the bytes. -
Every failure becomes one line of text through
errorToString, with the path to the problem. Changelevel = "info"tolevel = 3and the program reports:at logging.level: expected a string, found an integer -
That is the program's entire contact with TOML. From here on it holds a
Config, and nothing else in it knows there was a file.
A settings file the program creates, reads and updates
This is the shape a TUI or a desktop tool has: a settings file the program writes on the first run, the user edits by hand, and the program writes again on every save. The whole point of this package is that the user's edits, their comments and their arrangement survive that last step.
import Array exposing (Array)
import Toml
import Toml.Decode as Decode exposing (Decoder)
import Toml.Edit as Edit
type alias Settings =
{ theme : String
, zones : Array String
}
defaults : Settings
defaults =
{ theme = "dark", zones = [ "UTC" ] }
Loading. Three outcomes, and the difference between the second and the third is the one that matters:
load : FileSystem.Permission -> Path -> Task String Toml.Document
load fs path =
FileSystem.readFile fs path
|> Task.map Just
|> Task.onError
(\problem ->
if FileSystem.errorIsNoSuchFileOrDirectory problem then
Task.succeed Nothing
else
Task.fail (FileSystem.errorToString problem)
)
|> Task.andThen
(\found ->
when found is
Nothing ->
Task.succeed Toml.empty
Just bytes ->
when Toml.parseBytes bytes is
Ok document ->
Task.succeed document
Err problem ->
Task.fail (Toml.errorToString problem)
)
-
No file yet means
Toml.empty. That is the first run, and it is not a special case anywhere else in the program: every key is missing from an empty document, and the save below adds keys that are missing. -
A file that will not parse is an error, and stays one. Do not write
Result.withDefault Toml.emptyhere. It reads well and it means that a user who leaves a typo in the file gets an empty document written over their whole file on the next save. With the code above,theme = dark(no quotes) stops the program with:line 2, column 13: expected a value, but dark is not oneand the file on disk is exactly as they left it.
-
Any other I/O error is also an error. Permission denied is not "no file".
Reading the values out of the document you are holding:
settings : Decoder Settings
settings =
Decode.map2 (\theme zones -> { theme = theme, zones = zones })
(Decode.optionalField "theme" Decode.string
|> Decode.map (Maybe.withDefault defaults.theme)
)
(Decode.optionalField "zones" (Decode.array Decode.string)
|> Decode.map (Maybe.withDefault defaults.zones)
)
read : Toml.Document -> Result String Settings
read document =
Decode.fromDocument settings document
|> Result.mapError Decode.errorToString
- Every key is optional and has a default, so a missing key -- on the first
run, or because the user deleted a line -- is not an error. A key that is
there with the wrong type still is, which is what you want:
theme = 3is a mistake to report, not a default to fall back to. fromDocumentdecodes the document you already parsed. The program keeps theDocumentfor editing, so there is no reason to parse the file twice.
Writing the values back into that same document:
write : Settings -> Toml.Document -> Toml.Document
write current document =
document
|> Edit.introduce [ "theme" ]
{ blankBefore = True
, leading = [ " Colour scheme: \"dark\" or \"light\"." ]
, trailing = Nothing
}
(Edit.string current.theme)
|> Edit.introduce [ "zones" ]
{ blankBefore = True
, leading = [ " Time zones, in the order they are shown." ]
, trailing = Nothing
}
(Edit.array (Array.map Edit.string current.zones))
introduceissetplus an explanation, written only the first time. When the key is not in the document, the value goes in with the comment block above it. When the key is already there, only the value is set and the comments -- whatever the user has made of them since -- are left alone.setcompares before it writes. A key whose value has not changed is not touched at all, spacing and comments included, so writing every key on every save is safe. That is what letswritebe one function that does not need to know what changed.- Values are built with
Edit.string,Edit.int,Edit.arrayand the rest. Each one chooses a spelling for a value that has no source text yet. - Keys under a
[header]are the same call with a longer path:Edit.introduce [ "window", "width" ] ...brings the[window]header with it when there is none. To explain the section itself,Edit.setTableComments [ "window" ], guarded byEdit.memberon its first key so it happens once.
Saving:
save : FileSystem.Permission -> Path -> Toml.Document -> Task String {}
save fs path document =
FileSystem.writeFile fs (Toml.toBytes document) path
|> Task.mapError FileSystem.errorToString
|> Task.map (\_ -> {})
Toml.toBytesis the way back fromparseBytes, byte order mark included if there was one.writeFilecreates the file or overwrites it.
Putting it together. Keep the Document in your model next to the
Settings, because the document is the user's file and the settings are just
what you read out of it. On start, load, then read. On every save,
write settings document |> save fs path. There is no separate "first run"
code: the first run is a save onto Toml.empty.
Here is what that looks like on disk. The first run, with no file, writes:
# Colour scheme: "dark" or "light".
theme = "dark"
# Time zones, in the order they are shown.
zones = ["UTC"]
The user opens it, changes the theme, adds a note, and arranges the zones the way they like:
# Colour scheme: "dark" or "light".
theme = "light" # easier on the eyes at work
# Time zones, in the order they are shown.
zones = [
"Asia/Seoul", # them
"America/Chicago", # me
]
read now gives { theme = "light", zones = [ "Asia/Seoul", "America/Chicago" ] }.
The program switches the theme back to "dark" and saves everything:
# Colour scheme: "dark" or "light".
theme = "dark" # easier on the eyes at work
# Time zones, in the order they are shown.
zones = [
"Asia/Seoul", # them
"America/Chicago", # me
]
One value changed. The note beside it stayed, the array the user arranged by hand was not rewritten because it already said what the program was setting it to, and the explanations were not written a second time.
Editing
Toml.parseBytes source
|> Result.map (Toml.Edit.set [ "server", "port" ] (Toml.Edit.int 9090))
|> Result.map Toml.toString
set changes only the value: the key keeps its spelling, the equals sign keeps
the whitespace around it, and the comment on the line stays. A key that is not
there yet is added at the end of the table it belongs to, indented to match its
neighbours; if the table is not there either, a [header] comes with it.
A value that is already what you are setting it to is left exactly as it is
written, and that is the property that makes "write my whole configuration
back" safe to do on every save. Setting a value replaces the whitespace inside
it, so an array somebody arranged over four lines with a note against each
element would otherwise be flattened onto one -- on the key nobody changed,
because the program was saving some other key. The comparison is by value and
not by text: 0x1F and 31 are the same integer, 1.50 and 1.5 the same
number, so nothing is rewritten to say what it already said.
The value can be an array or an inline table as well as a scalar — array and
inlineTable write them on one line. Replacing the whole inline table is also
how to change something inside one, because a path stops at the brace: an
inline table has no lines to insert into, so the boundary is drawn there rather
than guessed at.
A path into an array of tables names its last item, which is the item TOML
itself means by [peer.tls] or peer.x = 1 after the second [[peer]].
Toml.Edit checks syntax and nothing else. A path that points at something a
key cannot be added beside -- inside an inline table, say -- produces a file that
Toml.Table.fromDocument will reject, for the same reason and with the same
message as if someone had typed it. Read an edited document back before writing
it out if the paths were not yours to begin with.
rename gives a key a new name and touches nothing else on the line, quoting
the name if the grammar will not take it bare. renameTable does the same for a
table, taking every header nested under it and every dotted key that spells the
name out — and leaving the last part of a dotted key alone, since that one names
the value rather than a table.
remove takes a key out along with the comments that belong to it. Which ones
those are is a convention, since TOML does not say, and it is this one:
- Leading — own-line comments directly above a key, no blank line between. They go when it goes.
- Trailing — a comment after the value on the same line.
- Floating — anything after a blank line belongs to nobody and survives.
- Header — comments before the first key belong to the document.
The blank line is the escape hatch, and it is worth knowing about: a comment block that introduces a whole section rather than the one key beneath it should have a blank line under it, or deleting that key will take the heading too.
setComments writes back what comments reads, in the same shape, so the two
are inverses. Adding a trailing comment to a line that had none puts a space in
front of it; taking one off takes that whitespace with it. blankBefore is
whether an empty line sits above the block, which is here because there is no
other way to ask for one -- a document is a list of expressions and has no
lines to insert between.
introduce is set and setComments in one call, except that the comments
are written only when the key was not already there. It is the shape a
program keeping a config file wants: every key it invents arrives with a
sentence saying what it is for, and the user who deletes that sentence, or
rewrites it in their own language, does not get corrected on the next save.
tableComments and setTableComments do the same for a [header] line, which
owns the block above it the way a key does -- removeTable already took it with
the table, and now it can be read and written. For an array of tables that is
the first [[header]], where Toml.Encode puts them, because the block
explains the key rather than one item; it is the one path in the module that
does not mean the last item.
Writing one that may not exist yet
Toml.empty is a document with nothing in it. Every key is missing from it and
set adds a key that is missing, so creating the file and updating it are the
same code:
(when found is
Nothing ->
Ok Toml.empty
Just bytes ->
Toml.parseBytes bytes
)
|> Result.map (Toml.Edit.introduce [ "theme" ] note (Toml.Edit.string "dark"))
|> Result.map Toml.toBytes
Without it a program needs a second path that writes the file from scratch, which is a second place to keep the shape of the file and the first place for the two to disagree.
Toml.empty is for a file that is not there. A file that is there and fails to
parse must stay an Err -- Result.withDefault Toml.empty would turn a typo in
the user's config into an empty document and write it back over their file on
the next save.
Writing one from scratch
Toml.Encode.toString
[ Toml.Encode.field "name" (Toml.Encode.string "widget")
, Toml.Encode.field "server"
(Toml.Encode.table
[ Toml.Encode.field "host" (Toml.Encode.string "example.com")
, Toml.Encode.field "port" (Toml.Encode.int 8080)
]
)
]
name = "widget"
[server]
host = "example.com"
port = 8080
commented explains a field, since a file nobody can read is not much better
than no file:
Toml.Encode.field "port" (Toml.Encode.int 8080)
|> Toml.Encode.commented
{ leading = [ " What to listen on." ], trailing = Just " http" }
There is no blank-line setting to go with it, unlike in Toml.Edit, because a
file this wrote has no author to have an opinion about its spacing: a commented
field gets a blank line above it, and not at the top of a file or under a
[header], where there is nothing above to be held off.
You choose the shape rather than a heuristic choosing it: table and
tableArray write [header] sections, inlineTable and array write braces
and brackets. Fields come out in the order you gave them — except that a table's
own keys are written before its sections, because a key written after a
[header] would land inside that header's table instead.
Two layers, and you pick
Toml.Ast is the file: a list of expressions in the order they appear,
each carrying the exact whitespace around it and the exact spelling of every
value. This is what an editing API works on.
Toml.Table is what the file says: keys resolved, dotted keys expanded,
arrays of tables assembled, in a Dict. It remembers nothing about
formatting, because none of that is part of what a TOML document means. This is
what a program that only wants to read its configuration should use.
It goes one way. A file is written from the AST, never from the table, because
the table is precisely what has forgotten how the file was written.
Toml.Decode.fromDocument reads a document you are already holding — one you
parsed in order to edit it — so that a program editing a file and a program
reading it need not parse it twice.
The second is built from the first, and building it is where a document gets
rejected for the things a grammar cannot see -- [a] written twice, a key
defined twice, a dotted key reaching into a table a header already defined.
Positions are not in the AST
A row number goes stale the moment a line is inserted above it. A column number survives that but cannot tell a tab from eight spaces, and cannot represent the alignment you chose between two tokens.
So instead of positions, each node holds the string that was actually there:
indent, wsBeforeEq, trailing. Rows and columns still exist in parse
errors, where they describe a file that is not about to change.
The same idea covers values. BigDecimal reads 1.50 and 1.5 as the same
number, which is correct and is exactly why a value alone cannot be written back
out. Every scalar carries its source text next to its meaning.
Start from the bytes
Toml.parseBytes : Bytes -> Result Error Document -- this one
Toml.parse : String -> Result Error Document -- for when you already have text
Toml.toBytes : Document -> Bytes
Toml.toString : Document -> String
toBytes is the way back, and the byte order mark rides along with it.
parseBytes is the real entry point, because two of the things that make a TOML
file invalid are decided before there is a String to look at. Bytes.toString
is a strict UTF-8 decode, so a bad byte sequence is caught there; by the time you
hold a String, the error has been repaired into U+FFFD, which is a legal TOML
character and cannot be found again. And decoding silently removes one leading
byte order mark, so only the bytes know whether there was one.
Conformance
Everything is checked against the official toml-test suite, filtered to the 1.1.0 manifest, read off the disk at run time.
| valid files that parse and lower | 220 / 220 |
| valid files that round-trip byte for byte | 220 / 220 |
| valid files whose values match the suite's JSON | 220 / 220 |
| invalid files rejected | 494 / 494 |
Nine of those invalid files never reach the parser: they are not valid UTF-8, deliberately, and the decode refuses them.
Syntactically-fine invalid files round-trip too, all 485 of them. That is not generosity -- it is the point. Round-tripping is a property of the AST, not a reward for being valid.
What it is built on
gilramir/gren-bignumfor integers with no width and decimals with no rounding, so9223372036854775807and0.1are both exact.gilramir/gren-civil-timefor the four date and time shapes, kept lexical so that-07:00survives instead of being normalised away.
Tests
devbox run test # 186 checks, ~0.9s, no network
devbox run conformance # the official toml-test runner; needs Go
First, the one thing that has to be beside the repo
The test corpus is a git submodule. vendor/toml-test holds the 714 files
the suite reads, and it is pinned to one commit — which is load-bearing rather
than tidiness, because the suites assert exact counts (220 valid files, 485
invalid ones, 32 date-times). A floating checkout would turn an upstream
addition into a local failure.
git clone --recurse-submodules <this repo>
If you already cloned without --recurse-submodules, vendor/toml-test is
an empty directory and three of the nine suites cannot start:
=== SUITE ERRORS ===
RoundTrip: setUpSuite: ENOENT: no such file or directory,
open '../vendor/toml-test/tests/files-toml-1.1.0'
Semantics: setUpSuite: ...
Values: setUpSuite: ...
FAILED — 178 passed, 0 failed, 11 errored
0 failed, 11 errored is the signature: nothing is wrong with the library,
three suites just have nothing to read. Fix it in place, no re-clone needed:
git submodule update --init
What the two commands check
devbox run test runs tests/, which reads the corpus off the disk rather than
from a baked-in fixture, so it follows the submodule rather than a snapshot of
it. Nine suites, in ~0.9s and with no network:
| suite | asks |
|---|---|
RoundTrip |
does every file come back from the writer byte for byte? |
Semantics |
are exactly the right files accepted and rejected? |
Values |
does each one read as the values toml-test's own JSON says? |
Numbers, Literals |
every number and string literal in the suite, in isolation |
Decoding, Editing, Encoding |
the three APIs, hand-written |
Examples |
the examples in the doc comments, against the file from toml.io |
The first three are the ones that need the submodule.
devbox run conformance shells into toml-test/ and runs the official Go
runner against both the decoder and encoder interfaces. It needs Go, and a
network the first time to install the runner into toml-test/.bin:
valid tests: 214 passed, 0 failed
encoder tests: 214 passed, 0 failed
invalid tests: 467 passed, 0 failed
The two runs are not redundant. toml-test embeds its own corpus and compares
with the official Go implementation; tests/ uses the newer pinned checkout, a
comparator written independently in Gren, and the byte-for-byte round trip,
which toml-test has no notion of. A bug has to fool two comparators about two
snapshots to get through.
Regenerating the fixtures
tests/src/Numbers.gren and tests/src/Literals.gren are generated from the
corpus. The Gren around the generated tables lives in tools/templates/, as
.gren files with jinja2 placeholders where the rows go, so the scripts hold
extraction and nothing else. Both reproduce their file byte for byte, so a diff
after regenerating means the template and the file have drifted:
devbox run gen # both scripts, from the repo root