Toml.Ast

The syntax of a TOML document, with every character of it kept.

This is the layer an editing API works on. It is a list of expressions in the order they appear in the file, and it holds the whitespace and the comments between them as text rather than as positions, so that writing an untouched document back out produces the same bytes it came from. That is a property of the shape of these types and not of anyone's care: there is nowhere for a character to go missing.

The other layer is the table tree, which is what a program that only wants to read a config file should use. It is built from this one, it is where the rules the grammar cannot express are checked, and it does not remember any of this.

Positions are not here, and that is deliberate

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 an author chose between two tokens. So instead of a position, each node holds the actual string that sat there -- wsBeforeEq, indent, trailing. Rows and columns still exist in parse errors, where they are about a file that is not going to change.

Raw text is kept next to every value

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: the file said 1.50. The same goes for 0x1F, 1_000_000, +7, the T or space in a date-time, and Z against z. Every scalar therefore carries both its source text and its meaning. The writer uses the text when the node was not touched and formats from the value when it was.

Comment ownership is not here either

Comments are expressions in the list, where the file put them. Which key a comment belongs to is a convention rather than a fact, so it is computed from this structure rather than baked into it -- otherwise changing the convention would mean changing the parser.

A file, and what it becomes

The example from https://toml.io:

[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"

parses to a Document of twenty expressions -- one per line, the blank ones included, and the twentieth being the empty expression after the final newline that says the file ended with one:

Toml.parse source
    |> Result.map (\doc -> Array.length doc.expressions)
--> Ok 20

Line three is a Pair, and it shows the two things this module is shaped around. The whitespace is text, and the value carries the spelling it was written with next to what it means:

{ indent = ""
, content =
    Pair
        { keyval =
            { key = { first = { raw = "dob", value = "dob" }, rest = [] }
            , wsBeforeEq = " "
            , wsAfterEq = " "
            , value =
                OffsetDateTimeVal
                    { raw = "1979-05-27T07:32:00-08:00"
                    , value = -- a Civil.DateTime
                    }
            }
        , trailing = ""
        , comment = Nothing
        }
, newline = Just Lf
}

ports = [ 8000, 8001, 8002 ] is where the trivia earns its place. The author put a space inside each bracket and none before the commas, and every one of those spaces is in the tree:

ArrayVal
    { elements =
        [ { before = [ Spaces " " ], value = IntegerVal { raw = "8000", .. }, after = [], comma = True }
        , { before = [ Spaces " " ], value = IntegerVal { raw = "8001", .. }, after = [], comma = True }
        , { before = [ Spaces " " ], value = IntegerVal { raw = "8002", .. }, after = [ Spaces " " ], comma = False }
        ]
    , trailing = []
    }

[servers.alpha] is a Header whose key has a rest, and the whitespace that could have sat around the dot is kept even though there is none here:

Header
    { header =
        { kind = Std
        , wsBefore = ""
        , key =
            { first = { raw = "servers", value = "servers" }
            , rest = [ { wsBefore = "", wsAfter = "", key = { raw = "alpha", value = "alpha" } } ]
            }
        , wsAfter = ""
        }
    , trailing = ""
    , comment = Nothing
    }

Note what is not here: [servers] on its own line is one expression and [servers.alpha] is another, with no link between them. That alpha lives inside servers is a fact about what the file means, not about how it is written, so it belongs to Toml.Table and is worked out there.

For reaching a value by path rather than by walking this list, see Toml.Edit.get. For what the file says, see Toml.Table.

type alias Document = { hasBom : Bool, expressions : Array Expression }

A whole TOML file.

hasBom records whether the source began with a byte order mark. It has to be stored rather than derived, because decoding UTF-8 removes exactly one leading BOM without saying so, and a document that had one must get it back.

type alias Expression =
{ indent : String
, content : Content
, newline : Maybe Newline
}

One expression, which is one line of the file plus the newline that ended it -- except that a value can be an array, an inline table or a multi-line string, any of which may run on for many physical lines. indent is the whitespace before the content began.

newline is Nothing only on the last expression of a file that does not end with one.

type Content
= Empty ({ comment : Maybe Comment })
| Pair ({ keyval : KeyVal, trailing : String, comment : Maybe Comment })
| Header ({ header : TableHeader, trailing : String, comment : Maybe Comment })

What an expression turned out to be. The grammar allows exactly three things on a line: nothing, a key and a value, or a table header. All three may carry a comment, and the two that are not empty may have whitespace after them.

Comments and line endings

type alias Comment = { text : String }

The text of a comment, without the # that started it and without the newline that ended it. It is kept exactly, including any leading space, because # note and #note are different files.

type Newline
= Lf
| CrLf

Which of the two line endings the grammar allows. TOML has no others, and a lone carriage return is not a line ending but an error.

Keys and values

type alias KeyVal =
{ key : Key
, wsBeforeEq : String
, wsAfterEq : String
, value : Value
}

A key, an equals sign and a value, with the whitespace around the equals sign kept so that a column of aligned assignments stays aligned.

type alias Key =
{ first : SimpleKey
, rest : Array { wsBefore : String, wsAfter : String, key : SimpleKey }
}

A key, which is one simple key or several joined by dots.

The dots carry whitespace on both sides, because a . b = 1 is legal and means what a.b = 1 means. rest is empty for an undotted key, which is why the first part is separate: there is always at least one.

type alias SimpleKey = { raw : String, value : String }

One part of a key.

raw is the source text, quotes and escapes included; value is the string the key actually is. For a bare key the two are equal. For "a\tb" they are not, and it is value that decides whether two keys collide.

Table headers

type alias TableHeader =
{ kind : TableKind
, wsBefore : String
, key : Key
, wsAfter : String
}

A [table] or [[array of tables]] line, with the whitespace inside the brackets kept.

type TableKind
= Std
| ArrayOfTables

Which kind of bracket the header used.

Values

type Value
= StringVal ({ raw : String, value : String })
| IntegerVal ({ raw : String, value : BigInt })
| FloatVal ({ raw : String, value : FloatValue })
| BooleanVal ({ raw : String, value : Bool })
| OffsetDateTimeVal ({ raw : String, value : DateTime })
| LocalDateTimeVal ({ raw : String, value : DateTime })
| LocalDateVal ({ raw : String, value : Date })
| LocalTimeVal ({ raw : String, value : Time })
| ArrayVal ArrayLit
| InlineTableVal InlineTable

A value, and next to it the text it was written as.

The raw of a composite value is not stored, because an array and an inline table already hold every character of themselves in their own fields.

type FloatValue
= Finite BigDecimal
| Infinite Sign
| NotANumber Sign

A TOML float.

Most of them are exact decimals, which is what BigDecimal is for: 0.1 there is 0.1 and not 0.1000000000000000055511151231257827. The other two are not numbers at all, and cannot be BigDecimal values, so they sit beside it.

TOML writes +nan and -nan and says nothing about what the sign means. It is kept so that the file can be written back as it was.

type Sign
= Positive
| Negative

Which sign a non-finite float was written with. nan with no sign is Positive, since that is how it is written back.

Arrays and inline tables

type alias ArrayLit = { elements : Array ArrayElement, trailing : Trivia }

An array literal.

Everything between the brackets is here. trailing is what sat between the last element -- or the opening bracket, in an empty array -- and the closing one.

type alias ArrayElement =
{ before : Trivia
, value : Value
, after : Trivia
, comma : Bool
}

One element of an array, with the trivia on each side of it and whether a comma followed.

Only the last element may have comma = False; TOML 1.1 allows the trailing comma but not a missing interior one.

type alias InlineTable = { entries : Array InlineEntry, trailing : Trivia }

An inline table.

Since TOML 1.1 this is the same shape as an array: newlines, comments and a trailing comma are all allowed inside the braces.

type alias InlineEntry =
{ before : Trivia
, keyval : KeyVal
, after : Trivia
, comma : Bool
}

One entry of an inline table. The same shape as an array element, with a key and a value instead of a value.

Trivia

type alias Trivia = Array TriviaPiece

Whitespace, comments and newlines, in the order they appeared.

This is the grammar's ws-comment-newline, which is what may sit between the parts of an array or an inline table. Concatenating the pieces gives back the source text exactly.

type TriviaPiece
= Spaces String
| Break ({ comment : Maybe Comment, newline : Newline })

One piece of trivia: a run of spaces and tabs, or the end of a line with an optional comment before it.

The two are separate because the grammar is: a comment inside an array has to be followed by a newline, so [1 # note ] is not a TOML array and [1 # note followed by ] on the next line is.