Toml.Edit
Changing a TOML file and leaving the rest of it alone.
Toml.parseBytes source
|> Result.map (Toml.Edit.set [ "server", "port" ] (Toml.Edit.int 9090))
|> Result.map Toml.toString
Every byte the edit did not touch comes back as it was: the blank lines, the
alignment, the comments, and the 0x1F you wrote instead of 31. That is what
this module is for, and it is why the AST underneath it stores whitespace as
text -- see Toml.Ast.
And an edit that changes nothing touches nothing. Setting a value that is
already what the file says leaves the line exactly as it is written -- see
set, where the reason it has to work that way is spelled out. It is
what makes "write my whole configuration back" a safe thing for a program to
do on every save.
Which comments belong to a key
Deleting a key should delete the comment that explains it and leave alone the one that explains the file. Nothing in TOML says which is which, so this is a convention, and here it is:
- Leading. Own-line comments directly above a key, with no blank line between, belong to it. They go when it goes.
- Trailing. A comment on the same line, after the value, belongs to it.
- 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 under it should have a blank line beneath it, or deleting that key will take the section heading with it.
A [header] line owns comments the same way a key does: the block directly
above it and the comment beside it are the header's. removeTable
takes them with the table, tableComments reads them and
setTableComments writes them.
What a path means here
A path is the key as the table tree sees it: [ "server", "port" ] finds
port under [server], and finds it equally whether the file wrote it as a
header and a key, as server.port = 9090, or as a header and a dotted key.
It does not reach inside an inline table or an array. a = {b = 1} has the path
[ "a" ] and nothing under it; to change b, replace the whole inline table.
That boundary is where the round trip stops being obvious -- an inline table has
no lines to insert into -- so it is drawn here rather than guessed at.
A path into an array of tables names its last item. That is what TOML
itself means by the path: a [peer.tls] header or a peer.x = 1 key after
the second [[peer]] belongs to the second one. So [ "peer", "x" ] reads,
changes or removes the x of the last [[peer]], and a key that is not there
yet is added to the last one too. The earlier items are reached by walking the
AST, which is what it is there for.
What this module does not check
Only syntax. Whether the file still means something afterwards is decided by
Toml.Table.fromDocument, as it is for a file
someone typed, and an edit can produce one it rejects. The ways that happen are
the ways a path can point at something a key cannot be added beside:
set [ "a", "b" ]whenais an inline table. The path does not reach in, so a[a]header is added forb, andais now defined twice.set [ "a", "b", "c" ]whena.b = 1wrotebas a dotted key. A[a.b]header is added, and a header may not land on a table a dotted key built.set [ "a" ]when[a]is a header. The key and the table collide.
None of those are refused here, because the rule that refuses them is a rule
about what the document means and lives in the layer that decides that. After
editing, read the result back through
Toml.Decode.fromDocument or
Toml.Table.fromDocument before writing it out, if
the paths were not yours to begin with.
A real example
The file from https://toml.io. Everything below starts from it, and only the lines that change are shown -- every other byte of the file comes back as it was, which is the point:
[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00
[database]
enabled = true
ports = [ 8000, 8001, 8002 ]
data = [ ["delta", "phi"], [3.14] ]
temp_targets = { cpu = 79.5, case = 72.0 }
[servers]
[servers.alpha]
ip = "10.0.0.1"
role = "frontend"
[servers.beta]
ip = "10.0.0.2"
role = "backend"
Change one value. The key, the equals sign and everything around them are untouched:
Toml.Edit.set [ "servers", "alpha", "ip" ] (Toml.Edit.string "10.0.0.9")
--> [servers.alpha]
--> ip = "10.0.0.9"
--> role = "frontend"
Change an array. A value written fresh is spelled by this module rather than by the file, so the author's spaces inside the brackets are not preserved -- there is nothing left of the old array to preserve them from:
Toml.Edit.set [ "database", "ports" ]
(Toml.Edit.array [ Toml.Edit.int 9000, Toml.Edit.int 9001 ])
--> ports = [9000, 9001]
Add a table. Neither [servers.gamma] nor the keys under it exist, so the
header comes with the first key and the second lands beside it:
Toml.Edit.set [ "servers", "gamma", "ip" ] (Toml.Edit.string "10.0.0.3")
>> Toml.Edit.set [ "servers", "gamma", "role" ] (Toml.Edit.string "cache")
--> [servers.beta]
--> ip = "10.0.0.2"
--> role = "backend"
-->
--> [servers.gamma]
--> ip = "10.0.0.3"
--> role = "cache"
Write comments. blankBefore is how a block gets an empty line above it,
which is the only way to ask for one -- a document is a list of expressions and
has no lines to insert between:
Toml.Edit.setComments [ "database", "enabled" ]
{ blankBefore = False
, leading = [ " Turn this off to go quiet." ]
, trailing = Just " on by default"
}
--> [database]
--> # Turn this off to go quiet.
--> enabled = true # on by default
Explain a section. A header owns the block above it the way a key does, and
setTableComments writes it:
Toml.Edit.setTableComments [ "servers", "alpha" ]
{ blankBefore = True, leading = [ " The primary." ], trailing = Nothing }
--> # The primary.
--> [servers.alpha]
--> ip = "10.0.0.1"
Write a value and explain it, but only the first time.
introduce is set plus the comments a key gets when
this is the call that invented it, and nothing at all to a key already in the
file:
Toml.Edit.introduce [ "database", "timeout" ]
{ blankBefore = True, leading = [ " Seconds." ], trailing = Nothing }
(Toml.Edit.int 30)
--> ports = [ 8000, 8001, 8002 ]
--> ...
-->
--> # Seconds.
--> timeout = 30
Rename. A key by rename and a table by
renameTable, which takes the headers nested under it too:
Toml.Edit.rename [ "servers", "alpha", "role" ] "job"
>> Toml.Edit.renameTable [ "database" ] "db"
--> [db]
--> enabled = true
--> ...
--> [servers.alpha]
--> ip = "10.0.0.1"
--> job = "frontend"
Remove a table, and its blank line goes with it:
Toml.Edit.removeTable [ "servers", "beta" ]
--> [servers.alpha]
--> ip = "10.0.0.1"
--> role = "frontend"
--> <end of file>
Put together, an edit is a pipeline from Toml.parseBytes to Toml.toString:
Toml.parseBytes source
|> Result.map
(Toml.Edit.set [ "database", "ports" ] (Toml.Edit.array [ Toml.Edit.int 9000 ])
>> Toml.Edit.set [ "servers", "gamma", "ip" ] (Toml.Edit.string "10.0.0.3")
>> Toml.Edit.renameTable [ "database" ] "db"
)
|> Result.map Toml.toString
To read the edited file back without writing it out and parsing it again, hand
the document to Toml.Decode.fromDocument.
The file that is not there yet
A program that keeps a config file has to handle its first run, and the tidy
way to do that is not to handle it: Toml.empty is a document
with nothing in it, every key is missing from it, and set adds a key
that is missing. So
(when found is
Nothing ->
Ok Toml.empty
Just bytes ->
Toml.parseBytes bytes
)
|> Result.map (Toml.Edit.introduce [ "theme" ] note (Toml.Edit.string "dark"))
creates the file and updates it with the same code, and there is no second
description of the file's shape to drift out of step with the first. The
when is not decoration: Toml.empty stands in for a file that is not there,
and a file that is there and fails to parse has to stay an Err, or the next
save writes an empty document over it -- see Toml.empty.
Toml.Encode is the other way to build a file from nothing, and
it is the better one when the shape of the file comes from the shape of the
data -- nested tables, arrays of tables, a section per item. This module is the
better one when the file has a fixed set of keys and the same code has to cope
with the file already existing.
The value at a path, if a key there holds one.
Whether a key is in the document at all.
get answers this too, but answering it with get means holding a
Toml.Ast.Value in order to throw it away, and the question a program writing
a config file asks is usually this one: is this key already here, or am I the
one putting it there? That is what decides whether it needs an explanation
written above it -- see introduce.
Put a value at a path.
If a key is already there, only its value changes: the key keeps its spelling, the equals sign keeps the whitespace around it, and the comment on the line stays where it was. Nothing else in the file moves.
If there is no such key, one is added. It goes at the end of the table it belongs
to, after the last key already in it, indented to match. If the table does not
exist either, a [header] for it is appended to the end of the file with the key
beneath and a blank line above, and a file that ended with a newline still does.
A top-level key added to a file that has no top-level keys yet goes before the
first [header], with a blank line between, since anything written after a
header belongs to that header's table. It goes above the comment block directly
over that header too, because that block is the header's by the convention
above; a comment with a blank line under it stays where it is.
A value that is already what you are setting it to is left exactly as it is written. This is not thrift, it is the difference between an editor and an encoder, and it is the one thing about this module worth reading twice.
Setting a value replaces the whitespace inside it, because the whitespace was part of the old value and the new one has none of its own. So a list somebody arranged by hand:
ports = [
8000, # http
8001 # https
]
written back with set [ "ports" ] (array [ int 8000, int 8001 ]) would come
out as ports = [8000, 8001]. That is right for a list that changed -- there
is no old formatting to keep for a new value -- and it is destructive for one
that did not.
Which is the case that actually happens. A program that writes its whole
configuration out on every save touches every key, so the key the user
hand-arranged gets flattened by a save about some other key. Comparing first
is what stops that, and doing it here rather than leaving it to every caller is
the only way it gets done: the caller would have to hold the old document, take
it apart, and know that 1.50 and 1.5 are the same number and 0x1F and
31 are the same integer.
The comparison is by value and not by text. A string is its characters after
escapes, an integer or float is its number whatever base or spelling it was
written in, a date-time is the instant and the offset, an array is its elements
in order, and an inline table is its entries in order. So set over 0x1F with
int 31 changes nothing and the file keeps saying 0x1F. To change the
spelling rather than the value, remove the key and set it again.
That takes the comments the key owned away with the line, so read them first
with comments and put them back with setComments
after.
An edit that gives the file a meaning it cannot have -- a key under an inline table, say -- is not refused here. See what this module does not check.
Set a value, and explain the key if this is the call that put it there.
Toml.Edit.introduce [ "theme" ]
{ blankBefore = True
, leading = [ " Which colour scheme to open in." ]
, trailing = Nothing
}
(Toml.Edit.string "dark")
set and setComments together, except that the
comments are written only when the key was not already in the document.
That condition is the whole reason this exists, and it is a rule about the person on the other end of the file rather than about TOML. A program that keeps a config file wants every key it invents to arrive with a sentence saying what the key is for, because a config file whose fields are undocumented is one nobody opens. It does not want to write that sentence again on every save: the user who deleted it meant to delete it, and the user who rewrote it in their own language has not made a mistake for the next save to correct.
On a document with the key already in it this is exactly set, comments and
all left alone. On Toml.empty it writes the key and the
explanation together, which is how a file that has never existed comes out
documented.
Take a key out, and the comments that belong to it with it.
The line goes, its leading comment block goes, and its trailing comment goes with the line it was on. A floating comment -- one with a blank line above it -- stays, which is the whole point of the blank line.
Nothing else changes. Removing the last key of a table leaves the [header]
behind, because an empty table is still a table and saying otherwise would be
this module deciding something the file did not.
Take a whole table out: its [header], everything under it, and the comments
that belong to the header.
Sub-tables go too, since [a.b] is under [a]. An array of tables named by the
path goes entirely, every item of it.
Renaming
Give a key a new name and change nothing else.
Toml.Edit.rename [ "server", "port" ] "listen"
--> [server]
--> listen = 8443 # was: port = 8443
The value stays as it was written, the whitespace around the equals sign stays, and the comments stay with the line. The new name is spelled bare where the grammar allows it and quoted where it does not.
Only the last part of the path is the name: this renames the key port, not the
table server. For a table see renameTable.
Whether the new name is already taken is not asked here. It is not a question
about syntax, so the answer comes from
Toml.Table.fromDocument, like every other question
of its kind -- see the two layers in Toml.Table.
Give a table a new name: its own [header], every header nested under it,
and every dotted key that spells the name out itself.
Toml.Edit.renameTable [ "server" ] "listener"
--> [listener]
--> [listener.tls]
Only the last part of the path is the name, as with rename, so a
table's ancestors never move and nothing has to be lifted into a different
section for the result to mean what it says.
A dotted key changes only where the renamed part is a table it passes through:
renameTable [ "a" ] "x" turns a.b = 1 into x.b = 1 and leaves b alone,
because b names the value rather than a table. By the same rule this does
nothing to a path that names a key: [ "server", "port" ] is not a table, and
rename is the one that renames it.
Comments
The comments that belong to one key.
{ blankBefore = True
, leading = [ " How long to wait." ]
, trailing = Just " seconds"
}
--> <a blank line>
--> # How long to wait.
--> timeout = 30 # seconds
leading and trailing are the text after the #, the leading space
included, because # note and #note are different files and neither is this
module's to choose.
blankBefore is whether an empty line sits above the block. It is here rather
than left to whoever is writing the file because there is no other way to ask
for one -- a document has no lines to insert, only expressions -- and because a
comment block hard against the line above it reads as a continuation of that
line. It is always False at the very top of a document, where a file does not
begin with a blank line, and for the first key directly under its [header],
which belongs against the header rather than held off it.
Set the comments that belong to a key, by the same convention
comments reads them by.
Toml.Edit.setComments [ "timeout" ]
{ blankBefore = True
, leading = [ " How long to wait." ]
, trailing = Just " seconds"
}
--> <a blank line>
--> # How long to wait.
--> timeout = 30 # seconds
comments and this are inverses, so what one reads the other writes back
unchanged -- including the blank line, which is why Comments has
a field for it.
leading = [] takes the block above the key away and trailing = Nothing takes
the comment off the line. Anything separated from the key by a blank line is not
the key's and is left alone, which is the same blank line that protects it from
remove. A key that is not there is left alone too.
blankBefore = True puts a blank line above the block if there is not one
already, and False takes one away if there is. Asking for one does nothing at
the top of the document, where there is no line above to be blank, and does
nothing directly under a [header], where the key is the first of its table and
a blank would hold it off the header it belongs to -- the same rule
Toml.Encode writes by. Taking one away is the
one thing here that reaches past the key's own lines, so it is worth knowing
what it does: the blank line is what separates a floating comment from an owned
one, and removing it makes the block above join this key's -- which is to say
it will go when this key goes.
The comments that belong to a table's [header] line: the block directly
above it and the comment beside it, by the same convention as a key's, and the
same ones removeTable takes with the table.
Toml.Edit.tableComments [ "servers", "alpha" ]
--> { blankBefore = True, leading = [ " The primary." ], trailing = Nothing }
For an array of tables the path means the first [[header]], which is the
other way round from every other path in this module. A path into an array of
tables names its last item when it names a value, because that is the item
TOML means by it. A comment over a header explains the key -- the whole array --
and the first header is where Toml.Encode writes it.
The other items are reached through the AST.
A table with no header line of its own -- one that only [a.b] implied, or one
a dotted key built -- has no line for a comment to be on, and reads as
{ blankBefore = False, leading = [], trailing = Nothing }. So does a table that
is not there.
Set the comments that belong to a table's [header] line, by the same
convention tableComments reads them by.
Toml.Edit.setTableComments [ "servers", "alpha" ]
{ blankBefore = True, leading = [ " The primary." ], trailing = Nothing }
--> <a blank line>
--> # The primary.
--> [servers.alpha]
The two are inverses, as comments and
setComments are, and for an array of tables the path means
the first [[header]] for the reason given there.
This is how a program explains a section it created. introduce
explains a key, and the header it brought with that key has nothing over it
until this is called; calling it only when member said the first
key was not there yet keeps the explanation to the run that invented the
section, which is the same rule introduce keeps for a key.
One difference from setComments: blankBefore = True directly under another
header is honoured. The first key of a table belongs against its header, but a
nested header directly under its parent is two sections meeting, and the blank
between them is the one Toml.Encode keeps. At the top of the
file it is still a no-op.
A table with no header line, or none at all, is left alone.
Looking around
Every key in the document, in the order the file writes them.
Table headers are not keys and are not here; the path of a key written under
[server] begins with server all the same.
The type a value has
A value on its way into a document, built by one of the functions at the bottom of this module.
The same type as Toml.Ast.Value, named here so that an edit
needs one import rather than two. The constructors are in Toml.Ast, for
reading a value apart; nothing here needs them, because a value is built by
string, int and the rest.
Values to put in
Each of these builds the value and the text for it, since a value that was not
read from a file has no text of its own yet. They are
Toml.Literal's, offered here so that an edit needs one import
rather than two, and shared with Toml.Encode so that the two
cannot disagree about how to spell a float.
A string.
An integer that fits in a Gren Int.
An integer of any size.
A float. inf and nan are written as those words; everything else gets a
decimal point even when it does not need one, since 1 without one is an
integer in TOML.
An exact decimal.
A boolean.
A date and time with an offset.
A date and time with no offset.
A date.
A time of day.
An array, on one line: [1, 2, 3].
Toml.Edit.set [ "ports" ]
(Toml.Edit.array [ Toml.Edit.int 80, Toml.Edit.int 443 ])
An array already in the file keeps every character of itself for as long as nothing is set over it, newlines and comments inside the brackets included. This is the spelling for one that is being written fresh.
An inline table, on one line: { a = 1, b = 2 }.
Toml.Edit.set [ "limits" ]
(Toml.Edit.inlineTable
[ { key = "soft", value = Toml.Edit.int 1024 }
, { key = "hard", value = Toml.Edit.int 4096 }
]
)
This is also how to change something inside an inline table, since a path does not reach in -- see what a path means. Build the table you want and set the whole of it.
There is no builder here for a [header] table, because a header is a line
rather than a value and set puts values on lines. A key under a header that
does not exist yet already brings one with it.
The comments that belong to a key, by the convention above.
This and
setCommentsare inverses: what one reads, the other writes back unchanged. A key that is not in the document has no comments and reads as{ blankBefore = False, leading = [], trailing = Nothing }.