//// A round-tripping TOML parser and writer.
////
//// Tomlet parses TOML into an opaque `Document`, preserves comments and
//// formatting during round-trips, and provides checked helpers for common
//// reads and edits.
import gleam/bit_array
import gleam/bool
import gleam/float
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import tomlet/ast
import tomlet/key as key_utils
import tomlet/parser
import tomlet/path
/// A parsed TOML document.
///
/// Documents are opaque so Tomlet can preserve round-trip invariants while the
/// internal syntax tree changes.
pub opaque type Document {
Document(
root: ast.Table,
trailing_trivia: String,
line_ending: LineEnding,
original_source: Option(String),
)
}
type LineEnding {
Lf
Crlf
}
/// Errors that can occur while parsing TOML input.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
pub type ParseError {
/// Raw bytes could not be decoded as valid TOML text.
InvalidEncoding
/// TOML syntax was invalid at a byte offset.
InvalidSyntax(kind: SyntaxErrorKind, offset: Int)
/// A key was defined more than once.
DuplicateKey(key: List(String), offset: Int)
}
/// Stable categories for TOML syntax errors.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
pub type SyntaxErrorKind {
/// A TOML value was expected.
ExpectedValue
/// A TOML key was expected.
ExpectedKey
/// A table header, such as `[table]` or `[[array.table]]`, was expected.
ExpectedTableHeader
/// TOML syntax was invalid, but the parser does not expose a narrower stable category.
///
/// This catches syntax errors that do not have a narrower stable category.
InvalidToml
}
/// Errors that can occur while reading typed values from a document.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
pub type GetError {
/// No value exists at the requested key path.
KeyNotFound(key: List(String))
/// A value exists at the requested key path, but it has a different TOML type.
WrongType(key: List(String), expected: ExpectedType)
}
/// TOML value kinds used in typed read errors.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
pub type ExpectedType {
ExpectedString
ExpectedInt
ExpectedBool
ExpectedFloat
ExpectedDate
ExpectedTime
ExpectedDateTime
}
/// A TOML value without internal formatting trivia.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
///
/// The table-shaped variants (`InlineTableValue`, `StandardTableValue`,
/// `ArrayOfTablesValue`) expose their entries as an ordered association list of
/// `#(key_path, value)` pairs and will remain shaped that way. The key path is
/// the dotted path relative to the table, e.g. `["pkg", "name"]` for an entry
/// written as `pkg.name = ...` inside an inline table.
pub type Value {
StringValue(String)
IntValue(Int)
FloatValue(Float)
SpecialFloatValue(SpecialFloat)
BoolValue(Bool)
DateValue(Date)
TimeValue(Time)
DateTimeValue(DateTime)
ArrayValue(List(Value))
InlineTableValue(List(#(List(String), Value)))
StandardTableValue(List(#(List(String), Value)))
ArrayOfTablesValue(List(List(#(List(String), Value))))
}
/// A TOML local date value.
///
/// Opaque so structured accessors can be added in a later release without
/// breaking existing code. Use `date_to_string` to read the original lexical
/// form (e.g. `"1979-05-27"`).
pub opaque type Date {
Date(text: String)
}
/// A TOML local time value.
///
/// Opaque so structured accessors can be added in a later release without
/// breaking existing code. Use `time_to_string` to read the original lexical
/// form (e.g. `"07:32:00"`).
pub opaque type Time {
Time(text: String)
}
/// A TOML date-time value.
///
/// Opaque so structured accessors can be added in a later release without
/// breaking existing code. Use `datetime_to_string` to read the original
/// lexical form (e.g. `"1979-05-27T07:32:00Z"`).
pub opaque type DateTime {
DateTime(text: String)
}
/// Errors that can occur while constructing typed values from raw text.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
pub type FormatError {
/// The text is not a valid TOML local date literal (`YYYY-MM-DD`).
InvalidDate(text: String)
/// The text is not a valid TOML local time literal (`HH:MM:SS[.fraction]`).
InvalidTime(text: String)
/// The text is not a valid TOML date-time literal.
InvalidDateTime(text: String)
}
/// Construct a `Date` from its TOML lexical form (e.g. `"1979-05-27"`).
///
/// ```gleam
/// let assert Ok(date) = tomlet.date_from_string("1979-05-27")
/// let assert Ok(doc) = tomlet.set_date(tomlet.new(), ["released"], date)
/// tomlet.to_string(doc)
/// // -> "released = 1979-05-27\n"
/// ```
pub fn date_from_string(text: String) -> Result(Date, FormatError) {
use <- bool.guard(
when: !parser.date_repr_is_valid(text),
return: Error(InvalidDate(text)),
)
Ok(Date(text))
}
/// Construct a `Time` from its TOML lexical form (e.g. `"07:32:00"`).
///
/// ```gleam
/// let assert Ok(time) = tomlet.time_from_string("07:32:00")
/// let assert Ok(doc) = tomlet.set_time(tomlet.new(), ["alarm"], time)
/// tomlet.to_string(doc)
/// // -> "alarm = 07:32:00\n"
/// ```
pub fn time_from_string(text: String) -> Result(Time, FormatError) {
use <- bool.guard(
when: !parser.time_repr_is_valid(text),
return: Error(InvalidTime(text)),
)
Ok(Time(text))
}
/// Construct a `DateTime` from its TOML lexical form
/// (e.g. `"1979-05-27T07:32:00Z"`).
///
/// ```gleam
/// let assert Ok(datetime) =
/// tomlet.datetime_from_string("1979-05-27T07:32:00Z")
/// let assert Ok(doc) =
/// tomlet.set_datetime(tomlet.new(), ["published"], datetime)
/// tomlet.to_string(doc)
/// // -> "published = 1979-05-27T07:32:00Z\n"
/// ```
pub fn datetime_from_string(text: String) -> Result(DateTime, FormatError) {
use <- bool.guard(
when: !parser.datetime_repr_is_valid(text),
return: Error(InvalidDateTime(text)),
)
Ok(DateTime(text))
}
/// Return the original lexical form of a TOML date value.
pub fn date_to_string(date: Date) -> String {
date.text
}
/// Return the original lexical form of a TOML time value.
pub fn time_to_string(time: Time) -> String {
time.text
}
/// Return the original lexical form of a TOML date-time value.
pub fn datetime_to_string(datetime: DateTime) -> String {
datetime.text
}
/// A TOML special floating-point value.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
///
/// ```gleam
/// let assert Ok(doc) = tomlet.parse("limit = inf\n")
/// let assert Ok(tomlet.SpecialFloatValue(tomlet.PositiveInfinity)) =
/// tomlet.get(doc, ["limit"])
/// ```
pub type SpecialFloat {
PositiveInfinity
NegativeInfinity
NotANumber
}
/// Errors that can occur while editing a document.
///
/// Variants are part of the stable public API. Adding, removing, or renaming a
/// variant is treated as a breaking change.
pub type EditError {
/// Edit paths must contain at least one key segment.
EmptyKeyPath
/// A key segment cannot be emitted as TOML.
InvalidKeySegment(segment: String)
/// Comments must be a single line.
InvalidCommentText
/// The edit requires an existing key, but no value exists at that key path.
MissingEditKey(key: List(String))
/// Inserting the key would conflict with an existing scalar, table, or array
/// of tables.
KeyConflict(key: List(String))
/// The edit would have to insert a new key inside an existing inline table.
///
/// Inline tables are written on a single line and cannot be extended in
/// place. Rewrite the table shape explicitly (for example, with `set_*` on a
/// standard table) instead of relying on implicit insertion.
InlineTableInsertUnsupported(key: List(String))
/// The supplied value cannot be represented in the requested edit context.
InvalidValue
}
/// Create an empty TOML document.
///
/// Equivalent to `parse("")` for downstream callers; the only observable
/// difference is that `parse("")` initially round-trips to `""` even after the
/// document is modified, while the document returned by `new` always emits its
/// current content.
pub fn new() -> Document {
Document(
root: ast.Table(entries: [], header: None),
trailing_trivia: "",
line_ending: Lf,
original_source: None,
)
}
/// The TOML language version to parse against.
pub type TomlVersion {
Toml10
Toml11
}
/// Parse TOML 1.1 text into a document. Use `parse_with(input, Toml10)` for
/// strict TOML 1.0 parsing that rejects 1.1-only syntax.
///
/// Successful parses return an opaque `Document` that preserves comments,
/// formatting trivia, key order, and the original line ending style for
/// round-tripping. Invalid text returns `ParseError`, including byte offsets for
/// syntax and duplicate-key diagnostics.
pub fn parse(input: String) -> Result(Document, ParseError) {
parse_string_with(input, Toml11)
}
/// Parse TOML text against the given language version.
pub fn parse_with(
input: String,
version: TomlVersion,
) -> Result(Document, ParseError) {
parse_string_with(input, version)
}
/// Parse a standalone TOML value literal.
///
/// The returned value uses Tomlet's stable public `Value` variants. Trailing
/// non-comment syntax is rejected rather than ignored.
///
/// ```gleam
/// tomlet.parse_value("\"tomato\"")
/// // -> Ok(tomlet.StringValue("tomato"))
///
/// tomlet.parse_value("[8000, 8001]")
/// // -> Ok(tomlet.ArrayValue([tomlet.IntValue(8000), tomlet.IntValue(8001)]))
/// ```
pub fn parse_value(input: String) -> Result(Value, ParseError) {
let key = "__tomlet_value__"
let prefix = key <> " = "
let source = prefix <> input <> "\n"
case parse_string_with(source, Toml11) {
Ok(doc) ->
case get(doc, [key]) {
Ok(value) -> Ok(value)
Error(_) -> Error(InvalidSyntax(InvalidToml, 0))
}
Error(error) -> Error(parse_value_error(error, string.byte_size(prefix)))
}
}
fn parse_value_error(error: ParseError, prefix_size: Int) -> ParseError {
case error {
InvalidEncoding -> InvalidEncoding
InvalidSyntax(kind, offset) ->
InvalidSyntax(kind, value_offset(offset, prefix_size))
DuplicateKey(_, offset) ->
InvalidSyntax(InvalidToml, value_offset(offset, prefix_size))
}
}
fn value_offset(offset: Int, prefix_size: Int) -> Int {
case offset < prefix_size {
True -> 0
False -> offset - prefix_size
}
}
/// Parse TOML bytes into a document.
///
/// This validates UTF-8 input and accepts a UTF-8 byte order mark only at the
/// start of the input.
///
/// ```gleam
/// let assert Ok(doc) = tomlet.parse_bytes(<<"answer = 42\n":utf8>>)
/// let assert Ok(answer) = tomlet.get_int(doc, ["answer"])
///
/// tomlet.parse_bytes(<<110, 97, 109, 101, 32, 61, 32, 255, 10>>)
/// // -> Error(tomlet.InvalidEncoding)
/// ```
pub fn parse_bytes(input: BitArray) -> Result(Document, ParseError) {
parse_bytes_versioned(input, Toml11)
}
/// Parse TOML bytes against the given language version.
pub fn parse_bytes_with(
input: BitArray,
version: TomlVersion,
) -> Result(Document, ParseError) {
parse_bytes_versioned(input, version)
}
fn parse_bytes_versioned(
input: BitArray,
version: TomlVersion,
) -> Result(Document, ParseError) {
// Strip a single leading UTF-8 BOM (0xEF 0xBB 0xBF). Pattern matching on the
// bytes avoids slice/length arithmetic and a fallback that could otherwise
// misreport a leading BOM as an embedded one.
let input_without_initial_bom = case input {
<<239, 187, 191, rest:bits>> -> rest
_ -> input
}
case bit_array_contains_utf8_bom(input_without_initial_bom) {
True -> Error(InvalidEncoding)
False ->
case bit_array.to_string(input_without_initial_bom) {
Ok(decoded) -> parse_string_with(decoded, version)
Error(_) -> Error(InvalidEncoding)
}
}
}
/// A one-based source position.
///
/// Positions are opaque so Tomlet can add more source-location details later
/// without changing the public constructor shape. Use `position_line` and
/// `position_column` to inspect one.
pub opaque type Position {
Position(line: Int, column: Int)
}
/// Convert a byte offset into a one-based line and column.
///
/// Offsets beyond the end of the input return the position just after the last
/// character. CRLF is treated as a single line break.
///
/// ```gleam
/// let input = "name = \n"
/// case tomlet.parse(input) {
/// Error(tomlet.InvalidSyntax(_, offset)) -> {
/// let position = tomlet.line_column(input, offset)
/// let line = tomlet.position_line(position)
/// let column = tomlet.position_column(position)
/// // Show line and column in your application's diagnostic.
/// }
/// _ -> Nil
/// }
/// ```
pub fn line_column(input: String, offset: Int) -> Position {
let #(line, column) =
line_column_loop(string.to_utf_codepoints(input), offset, 0, 1, 1)
Position(line: line, column: column)
}
/// Return the one-based line number for a source position.
pub fn position_line(position: Position) -> Int {
position.line
}
/// Return the one-based column number for a source position.
pub fn position_column(position: Position) -> Int {
position.column
}
fn line_column_loop(
codepoints: List(UtfCodepoint),
target: Int,
current: Int,
line: Int,
column: Int,
) -> #(Int, Int) {
case current >= target, codepoints {
True, _ -> #(line, column)
False, [] -> #(line, column)
False, [first, second, ..rest] -> {
case
string.utf_codepoint_to_int(first),
string.utf_codepoint_to_int(second)
{
13, 10 -> line_column_loop(rest, target, current + 2, line + 1, 1)
_, _ ->
line_column_next(
[first, second, ..rest],
target,
current,
line,
column,
)
}
}
False, remaining ->
line_column_next(remaining, target, current, line, column)
}
}
fn line_column_next(
codepoints: List(UtfCodepoint),
target: Int,
current: Int,
line: Int,
column: Int,
) -> #(Int, Int) {
case codepoints {
[] -> #(line, column)
[codepoint, ..rest] -> {
case string.utf_codepoint_to_int(codepoint) {
10 -> line_column_loop(rest, target, current + 1, line + 1, 1)
13 -> line_column_loop(rest, target, current + 1, line + 1, 1)
_ -> {
let width = string.byte_size(string.from_utf_codepoints([codepoint]))
line_column_loop(rest, target, current + width, line, column + 1)
}
}
}
}
}
fn bit_array_contains_utf8_bom(input: BitArray) -> Bool {
case input {
<<>> -> False
<<239, 187, 191, _rest:bits>> -> True
<<_, rest:bits>> -> bit_array_contains_utf8_bom(rest)
_ -> False
}
}
fn parse_string_with(
input: String,
version: TomlVersion,
) -> Result(Document, ParseError) {
let line_ending = case string.contains(input, "\r\n") {
True -> Crlf
False -> Lf
}
let input_without_initial_bom = case string.to_graphemes(input) {
["\u{FEFF}", ..rest] -> string.concat(rest)
_ -> input
}
case string.contains(input_without_initial_bom, "\u{FEFF}") {
True -> Error(InvalidEncoding)
False -> {
// Parsed AST source_text is LF-only; CRLF is tracked separately on Document.
let normalized = string.replace(input_without_initial_bom, "\r\n", "\n")
case parser.parse(normalized, to_parser_version(version)) {
Ok(root) ->
Ok(Document(
root: root,
trailing_trivia: "",
line_ending: line_ending,
original_source: Some(input),
))
Error(parser.Unexpected(_got, expected, offset)) ->
Error(InvalidSyntax(
syntax_error_kind(expected),
normalized_offset_to_original(input, offset),
))
Error(parser.KeyAlreadyInUse(key, offset)) ->
Error(DuplicateKey(key, normalized_offset_to_original(input, offset)))
}
}
}
}
fn to_parser_version(version: TomlVersion) -> parser.Version {
case version {
Toml10 -> parser.Toml10
Toml11 -> parser.Toml11
}
}
fn normalized_offset_to_original(input: String, target: Int) -> Int {
normalized_offset_to_original_loop(
string.to_utf_codepoints(input),
target,
0,
0,
True,
)
}
fn normalized_offset_to_original_loop(
codepoints: List(UtfCodepoint),
target: Int,
normalized: Int,
original: Int,
at_start: Bool,
) -> Int {
case normalized >= target, codepoints {
True, _ -> original
False, [] -> original
False, [first, second, ..rest] -> {
case
string.utf_codepoint_to_int(first),
string.utf_codepoint_to_int(second),
at_start
{
65_279, _, True ->
normalized_offset_to_original_loop(
[second, ..rest],
target,
normalized,
original + 3,
False,
)
13, 10, _ ->
normalized_offset_to_original_loop(
rest,
target,
normalized + 1,
original + 2,
False,
)
_, _, _ ->
normalized_offset_to_original_next(
[first, second, ..rest],
target,
normalized,
original,
)
}
}
False, remaining ->
normalized_offset_to_original_next(
remaining,
target,
normalized,
original,
)
}
}
fn normalized_offset_to_original_next(
codepoints: List(UtfCodepoint),
target: Int,
normalized: Int,
original: Int,
) -> Int {
case codepoints {
[] -> original
[codepoint, ..rest] -> {
let width = string.byte_size(string.from_utf_codepoints([codepoint]))
normalized_offset_to_original_loop(
rest,
target,
normalized + width,
original + width,
False,
)
}
}
}
fn syntax_error_kind(expected: parser.ExpectedTokenKind) -> SyntaxErrorKind {
case expected {
parser.ExpectedValue -> ExpectedValue
parser.ExpectedKey -> ExpectedKey
parser.ExpectedTableHeader -> ExpectedTableHeader
parser.ExpectedSyntax -> InvalidToml
}
}
/// Emit a document as TOML text.
///
/// Unedited parsed documents round-trip to their original source text.
pub fn to_string(doc: Document) -> String {
case doc.original_source {
Some(source) -> source
None -> {
let output = emit_table(doc.root) <> doc.trailing_trivia
case doc.line_ending {
Lf -> output
// Safe because every stored source_text fragment was normalized to LF.
Crlf -> string.replace(output, each: "\n", with: "\r\n")
}
}
}
}
// Replace the document's root and clear the cached original source so the next
// `to_string` re-emits from the (now-edited) AST. Every edit must produce its
// result through this helper; relying on each mutator to remember to clear the
// cache by hand risks silently emitting the pre-edit text.
fn with_root(doc: Document, root: ast.Table) -> Document {
Document(..doc, root: root, original_source: None)
}
/// Read a TOML value at a key path.
///
/// Use `get` instead of the typed `get_*` helpers when you need to inspect
/// arrays, inline tables, standard tables, arrays of tables, or special floats.
///
/// Path segments that name an array or array of tables can be followed by a
/// non-negative decimal index to descend into it, e.g.
/// `get(doc, ["packages", "0", "name"])`. For a typed read of an indexed path,
/// compose with the `as_*` converters: `get(doc, path) |> result.try(as_string)`.
///
/// ```gleam
/// let assert Ok(doc) =
/// tomlet.parse("package = { name = \"tomato\", downloads = 42 }\n")
/// let assert Ok(value) = tomlet.get(doc, ["package"])
/// // -> tomlet.InlineTableValue([
/// // #(["name"], tomlet.StringValue("tomato")),
/// // #(["downloads"], tomlet.IntValue(42)),
/// // ])
/// ```
pub fn get(doc: Document, key: List(String)) -> Result(Value, GetError) {
case get_value(doc, key) {
Ok(value) -> Ok(public_value(value))
// A missing scalar/value falls through to a table lookup, then to indexed
// descent (e.g. `["packages", "0", "name"]`); any other error is surfaced
// rather than masked as "not found".
Error(KeyNotFound(_)) ->
case get_table_value(doc, key) {
Ok(value) -> Ok(value)
Error(KeyNotFound(_)) -> get_indexed(doc, key)
Error(error) -> Error(error)
}
Error(error) -> Error(error)
}
}
// Resolve a key path that descends into arrays or arrays of tables by index
// (e.g. `["packages", "0", "name"]`). The longest prefix that resolves to a
// value via `get` is found, then the remaining segments are walked with
// `value_get`, which shares the same index rules. Returns `KeyNotFound` with
// the full key when no split resolves.
fn get_indexed(doc: Document, key: List(String)) -> Result(Value, GetError) {
get_indexed_loop(doc, key, list.length(key) - 1)
}
fn get_indexed_loop(
doc: Document,
key: List(String),
split: Int,
) -> Result(Value, GetError) {
use <- bool.guard(when: split < 1, return: Error(KeyNotFound(key)))
get(doc, list.take(key, split))
|> result.try(value_get(_, list.drop(key, split)))
|> result.lazy_or(fn() { get_indexed_loop(doc, key, split - 1) })
}
/// Read a TOML string value at a key path.
pub fn get_string(
doc: Document,
key: List(String),
) -> Result(String, GetError) {
case get_value(doc, key) {
Ok(ast.String(value, _, _)) -> Ok(value)
Ok(_) -> Error(WrongType(key, ExpectedString))
Error(error) -> Error(error)
}
}
/// Read a TOML integer value at a key path.
pub fn get_int(doc: Document, key: List(String)) -> Result(Int, GetError) {
case get_value(doc, key) {
Ok(ast.Int(value, _)) -> Ok(value)
Ok(_) -> Error(WrongType(key, ExpectedInt))
Error(error) -> Error(error)
}
}
/// Read a TOML boolean value at a key path.
pub fn get_bool(doc: Document, key: List(String)) -> Result(Bool, GetError) {
case get_value(doc, key) {
Ok(ast.Bool(value, _)) -> Ok(value)
Ok(_) -> Error(WrongType(key, ExpectedBool))
Error(error) -> Error(error)
}
}
/// Read a TOML float value at a key path.
///
/// Special floats (`inf`, `-inf`, `nan`) are not returned here; reading one
/// yields `WrongType`. Use `get` and match on `SpecialFloatValue` for those.
pub fn get_float(doc: Document, key: List(String)) -> Result(Float, GetError) {
case get_value(doc, key) {
Ok(ast.Float(value, _)) -> Ok(value)
Ok(_) -> Error(WrongType(key, ExpectedFloat))
Error(error) -> Error(error)
}
}
/// Read a TOML local date value at a key path.
pub fn get_date(doc: Document, key: List(String)) -> Result(Date, GetError) {
case get_value(doc, key) {
Ok(ast.Date(source_text)) -> Ok(Date(source_text))
Ok(_) -> Error(WrongType(key, ExpectedDate))
Error(error) -> Error(error)
}
}
/// Read a TOML local time value at a key path.
pub fn get_time(doc: Document, key: List(String)) -> Result(Time, GetError) {
case get_value(doc, key) {
Ok(ast.Time(source_text)) -> Ok(Time(source_text))
Ok(_) -> Error(WrongType(key, ExpectedTime))
Error(error) -> Error(error)
}
}
/// Read a TOML date-time value at a key path.
pub fn get_datetime(
doc: Document,
key: List(String),
) -> Result(DateTime, GetError) {
case get_value(doc, key) {
Ok(ast.DateTime(source_text)) -> Ok(DateTime(source_text))
Ok(_) -> Error(WrongType(key, ExpectedDateTime))
Error(error) -> Error(error)
}
}
/// Return the top-level keys of the table at a key path, in source order.
///
/// Works on standard tables (`[table]`) and inline tables (`{ ... }`).
/// Dotted keys and subtables collapse to their first segment, so
/// `a.b` and `[t.sub]` both contribute a single `"a"` / `"sub"` key, and
/// duplicates are removed while preserving first-occurrence order.
///
/// A missing path yields `KeyNotFound`. A path that resolves to a non-table
/// value (including an array of tables) yields `WrongType`. The
/// `ExpectedType` reported for the non-table case is a placeholder until a
/// dedicated `ExpectedTable` variant is introduced (see issue #28); match on
/// the `WrongType` constructor rather than the specific `ExpectedType`.
///
/// ```gleam
/// let assert Ok(doc) =
/// tomlet.parse("[dependencies]\ngleam_stdlib = \">= 0.40.0\"\n")
/// tomlet.table_keys(doc, ["dependencies"])
/// // -> Ok(["gleam_stdlib"])
/// ```
pub fn table_keys(
doc: Document,
path: List(String),
) -> Result(List(String), GetError) {
case get(doc, path) {
Ok(StandardTableValue(entries)) -> Ok(top_level_keys(entries))
Ok(InlineTableValue(entries)) -> Ok(top_level_keys(entries))
Ok(_) -> Error(WrongType(path, ExpectedString))
Error(error) -> Error(error)
}
}
// Collect the first segment of each entry key, preserving source order and
// removing duplicates introduced by dotted keys or subtables.
fn top_level_keys(entries: List(#(List(String), Value))) -> List(String) {
entries
|> list.filter_map(fn(entry) {
case entry.0 {
[first, ..] -> Ok(first)
[] -> Error(Nil)
}
})
|> list.unique
}
/// Read a string from a `Value`.
///
/// Mirrors `get_string`, but operates on a `Value` already obtained via `get`,
/// so nested data can be decoded without re-walking from the document root. On
/// a type mismatch the error carries an empty key path, since a bare `Value`
/// has no path context.
pub fn as_string(value: Value) -> Result(String, GetError) {
case value {
StringValue(text) -> Ok(text)
_ -> Error(WrongType([], ExpectedString))
}
}
/// Read an integer from a `Value`. See `as_string` for the error convention.
pub fn as_int(value: Value) -> Result(Int, GetError) {
case value {
IntValue(number) -> Ok(number)
_ -> Error(WrongType([], ExpectedInt))
}
}
/// Read a boolean from a `Value`. See `as_string` for the error convention.
pub fn as_bool(value: Value) -> Result(Bool, GetError) {
case value {
BoolValue(boolean) -> Ok(boolean)
_ -> Error(WrongType([], ExpectedBool))
}
}
/// Read a float from a `Value`. See `as_string` for the error convention.
///
/// Special floats (`inf`, `-inf`, `nan`) are not returned here; reading one
/// yields `WrongType`. Match on `SpecialFloatValue` for those.
pub fn as_float(value: Value) -> Result(Float, GetError) {
case value {
FloatValue(number) -> Ok(number)
_ -> Error(WrongType([], ExpectedFloat))
}
}
/// Read a local date from a `Value`. See `as_string` for the error convention.
pub fn as_date(value: Value) -> Result(Date, GetError) {
case value {
DateValue(date) -> Ok(date)
_ -> Error(WrongType([], ExpectedDate))
}
}
/// Read a local time from a `Value`. See `as_string` for the error convention.
pub fn as_time(value: Value) -> Result(Time, GetError) {
case value {
TimeValue(time) -> Ok(time)
_ -> Error(WrongType([], ExpectedTime))
}
}
/// Read a date-time from a `Value`. See `as_string` for the error convention.
pub fn as_datetime(value: Value) -> Result(DateTime, GetError) {
case value {
DateTimeValue(datetime) -> Ok(datetime)
_ -> Error(WrongType([], ExpectedDateTime))
}
}
/// Descend into a `Value` by key path without returning to the document root.
///
/// Table-shaped values are descended by key; arrays and arrays of tables are
/// descended by a non-negative decimal index (e.g. `"0"`). An empty path
/// returns the value unchanged. A missing key, a non-numeric or out-of-range
/// index, or descent into a scalar all yield `KeyNotFound`.
///
/// ```gleam
/// let assert Ok(doc) =
/// tomlet.parse("[[packages]]\nname = \"gleam_stdlib\"\n")
/// let assert Ok(packages) = tomlet.get(doc, ["packages"])
/// tomlet.value_get(packages, ["0", "name"])
/// // -> Ok(tomlet.StringValue("gleam_stdlib"))
/// ```
pub fn value_get(value: Value, key: List(String)) -> Result(Value, GetError) {
case key, value {
[], _ -> Ok(value)
[head, ..rest], InlineTableValue(entries) ->
value_get_table(entries, head, rest, key)
[head, ..rest], StandardTableValue(entries) ->
value_get_table(entries, head, rest, key)
[head, ..rest], ArrayValue(items) -> value_get_index(items, head, rest, key)
[head, ..rest], ArrayOfTablesValue(tables) ->
value_get_index(list.map(tables, StandardTableValue), head, rest, key)
_, _ -> Error(KeyNotFound(key))
}
}
fn value_get_table(
entries: List(#(List(String), Value)),
head: String,
rest: List(String),
key: List(String),
) -> Result(Value, GetError) {
let matched =
list.filter_map(entries, fn(entry) {
let #(entry_key, entry_value) = entry
case entry_key {
[first, ..tail] if first == head -> Ok(#(tail, entry_value))
_ -> Error(Nil)
}
})
case matched, rest {
[], _ -> Error(KeyNotFound(key))
[#([], inner)], [] -> Ok(inner)
[#([], inner)], _ -> value_get(inner, rest)
_, [] -> Ok(StandardTableValue(matched))
_, _ -> value_get(StandardTableValue(matched), rest)
}
}
fn value_get_index(
items: List(Value),
index_text: String,
rest: List(String),
key: List(String),
) -> Result(Value, GetError) {
case int.parse(index_text) {
Ok(index) if index >= 0 ->
case items |> list.drop(index) |> list.first {
Ok(item) -> value_get(item, rest)
Error(Nil) -> Error(KeyNotFound(key))
}
_ -> Error(KeyNotFound(key))
}
}
fn get_value(doc: Document, key: List(String)) -> Result(ast.Value, GetError) {
path.get(doc.root, key)
|> result.replace_error(KeyNotFound(key))
}
fn get_table_value(
doc: Document,
key: List(String),
) -> Result(Value, GetError) {
case key {
[] -> Error(KeyNotFound(key))
_ -> {
let Document(root: ast.Table(entries: entries, ..), ..) = doc
let #(table_entries, found) =
collect_table_entries(entries, [], key, False, [])
case found {
True -> Ok(StandardTableValue(table_entries))
False -> Error(KeyNotFound(key))
}
}
}
}
fn public_value(value: ast.Value) -> Value {
case value {
ast.Int(value, source_text: _) -> IntValue(value)
ast.Float(value, source_text: _) -> FloatValue(value)
ast.SpecialFloat(value, source_text: _) ->
SpecialFloatValue(public_special_float(value))
ast.Bool(value, source_text: _) -> BoolValue(value)
ast.String(value, style: _, source_text: _) -> StringValue(value)
ast.Date(source_text) -> DateValue(Date(source_text))
ast.Time(source_text) -> TimeValue(Time(source_text))
ast.DateTime(source_text) -> DateTimeValue(DateTime(source_text))
ast.Array(items, source_text: _) ->
ArrayValue(list.map(items, public_array_item))
ast.InlineTable(entries, source_text: _) ->
InlineTableValue(list.map(entries, public_inline_table_entry))
ast.ArrayOfTables(items) ->
ArrayOfTablesValue(list.map(items, public_table_entries))
}
}
fn public_special_float(value: ast.SpecialFloat) -> SpecialFloat {
case value {
ast.PositiveInfinity -> PositiveInfinity
ast.NegativeInfinity -> NegativeInfinity
ast.NotANumber -> NotANumber
}
}
fn public_array_item(item: ast.ArrayItem) -> Value {
let ast.ArrayItem(leading: _, value: value, trailing: _) = item
public_value(value)
}
fn public_inline_table_entry(
entry: ast.InlineTableEntry,
) -> #(List(String), Value) {
let ast.InlineTableEntry(leading: _, key: key, value: value, trailing: _) =
entry
#(key_to_strings(key), public_value(value))
}
fn public_table_entries(table: ast.Table) -> List(#(List(String), Value)) {
let ast.Table(entries: entries, header: _) = table
let #(table_entries, _) = collect_table_entries(entries, [], [], True, [])
table_entries
}
fn entry_defines_target_table(entry: ast.Entry, target: List(String)) -> Bool {
case entry {
ast.TableHeader(header) ->
header_is_standard_table(header) && header_key(header) == target
_ -> False
}
}
fn collect_key_value_entry(
key: ast.Key,
value: ast.Value,
rest: List(ast.Entry),
active_table: List(String),
next_active_table: List(String),
target: List(String),
next_found: Bool,
collected: List(#(List(String), Value)),
) -> #(List(#(List(String), Value)), Bool) {
let full_key = list.append(active_table, key_to_strings(key))
case key_utils.starts_with(full_key, target) && full_key != target {
True ->
collect_table_entries(rest, next_active_table, target, True, [
#(list.drop(full_key, list.length(target)), public_value(value)),
..collected
])
False ->
collect_table_entries(
rest,
next_active_table,
target,
next_found,
collected,
)
}
}
fn collect_table_entries(
entries: List(ast.Entry),
active_table: List(String),
target: List(String),
found: Bool,
collected: List(#(List(String), Value)),
) -> #(List(#(List(String), Value)), Bool) {
case entries {
[] -> #(list.reverse(collected), found)
[entry, ..rest] -> {
let next_active_table = case entry {
ast.TableHeader(header) -> header_key(header)
_ -> active_table
}
let next_found = found || entry_defines_target_table(entry, target)
case entry {
ast.KeyValue(key: key, value: value, ..) ->
collect_key_value_entry(
key,
value,
rest,
active_table,
next_active_table,
target,
next_found,
collected,
)
_ ->
collect_table_entries(
rest,
next_active_table,
target,
next_found,
collected,
)
}
}
}
}
/// Set a TOML string value at a key path.
///
/// Existing values are replaced in place. Missing keys are inserted, creating a
/// table header when needed.
///
/// ```gleam
/// let assert Ok(doc) =
/// tomlet.set_string(tomlet.new(), ["package", "name"], "tomlet")
/// tomlet.to_string(doc)
/// // -> "
/// // [package]
/// // name = \"tomlet\"
/// // "
/// ```
pub fn set_string(
doc: Document,
key: List(String),
value: String,
) -> Result(Document, EditError) {
set_value(
doc,
key,
ast.String(value, ast.BasicString, basic_string_repr(value)),
)
}
/// Set a TOML integer value at a key path.
///
/// Existing values are replaced in place. Missing keys are inserted, creating a
/// table header when needed.
pub fn set_int(
doc: Document,
key: List(String),
value: Int,
) -> Result(Document, EditError) {
set_value(doc, key, ast.Int(value, int.to_string(value)))
}
/// Set a TOML boolean value at a key path.
///
/// Existing values are replaced in place. Missing keys are inserted, creating a
/// table header when needed.
pub fn set_bool(
doc: Document,
key: List(String),
value: Bool,
) -> Result(Document, EditError) {
let repr = case value {
True -> "true"
False -> "false"
}
set_value(doc, key, ast.Bool(value, repr))
}
/// Set a TOML float value at a key path.
///
/// Existing values are replaced in place. Missing keys are inserted, creating a
/// table header when needed.
pub fn set_float(
doc: Document,
key: List(String),
value: Float,
) -> Result(Document, EditError) {
set_value(doc, key, ast.Float(value, float.to_string(value)))
}
/// Set a TOML local date value at a key path.
///
/// Existing values are replaced in place. Missing keys are inserted, creating a
/// table header when needed.
pub fn set_date(
doc: Document,
key: List(String),
value: Date,
) -> Result(Document, EditError) {
set_value(doc, key, ast.Date(value.text))
}
/// Set a TOML local time value at a key path.
///
/// Existing values are replaced in place. Missing keys are inserted, creating a
/// table header when needed.
pub fn set_time(
doc: Document,
key: List(String),
value: Time,
) -> Result(Document, EditError) {
set_value(doc, key, ast.Time(value.text))
}
/// Set a TOML date-time value at a key path.
///
/// Existing values are replaced in place. Missing keys are inserted, creating a
/// table header when needed.
pub fn set_datetime(
doc: Document,
key: List(String),
value: DateTime,
) -> Result(Document, EditError) {
set_value(doc, key, ast.DateTime(value.text))
}
/// Set a TOML array value at a key path.
///
/// Items are emitted in order using a default flow-style representation
/// (`[a, b, c]`). Existing values are replaced in place. Missing keys are
/// inserted, creating a table header when needed.
///
/// `StandardTableValue` and `ArrayOfTablesValue` items are rejected with
/// `InvalidValue`; use `set_inline_table` or `append_array_of_tables` for
/// table-shaped values.
///
/// ```gleam
/// let assert Ok(doc) =
/// tomlet.set_array(tomlet.new(), ["ports"], [
/// tomlet.IntValue(8000),
/// tomlet.IntValue(8001),
/// ])
/// tomlet.to_string(doc)
/// // -> "ports = [8000, 8001]\n"
/// ```
pub fn set_array(
doc: Document,
key: List(String),
items: List(Value),
) -> Result(Document, EditError) {
case validate_values(items) {
Error(error) -> Error(error)
Ok(Nil) -> {
use ast_items <- result.try(list.try_map(items, value_to_array_item))
set_value(doc, key, ast.Array(ast_items, emit_array_items(ast_items)))
}
}
}
/// Set a TOML inline table value at a key path.
///
/// Entries are emitted in order using a default flow-style representation
/// (`{ a = 1, b = 2 }`). Each entry's key path is rendered as a dotted key
/// when it contains more than one segment. Existing values are replaced in
/// place. Missing keys are inserted, creating a table header when needed.
///
/// Entry values that are `StandardTableValue` or `ArrayOfTablesValue` are
/// rejected with `InvalidValue`; nest an `InlineTableValue` instead.
///
/// ```gleam
/// let assert Ok(doc) =
/// tomlet.set_inline_table(tomlet.new(), ["pkg"], [
/// #(["name"], tomlet.StringValue("tomato")),
/// #(["meta", "downloads"], tomlet.IntValue(42)),
/// ])
/// tomlet.to_string(doc)
/// // -> "pkg = { name = \"tomato\", meta.downloads = 42 }\n"
/// ```
pub fn set_inline_table(
doc: Document,
key: List(String),
entries: List(#(List(String), Value)),
) -> Result(Document, EditError) {
case validate_table_entries(entries) {
Error(error) -> Error(error)
Ok(Nil) -> {
use ast_entries <- result.try(list.try_map(entries, value_to_inline_entry))
set_value(
doc,
key,
ast.InlineTable(ast_entries, emit_inline_table(ast_entries)),
)
}
}
}
/// Append a new table to an array of tables at a key path.
///
/// A `[[key]]` header is appended to the document followed by the supplied
/// entries. Works whether or not an array of tables already exists at the key
/// path; if no array of tables exists yet, a new one is created.
///
/// ```gleam
/// let assert Ok(doc) =
/// tomlet.append_array_of_tables(tomlet.new(), ["packages"], [
/// #(["name"], tomlet.StringValue("tomato")),
/// ])
/// tomlet.to_string(doc)
/// // -> "
/// // [[packages]]
/// // name = \"tomato\"
/// // "
/// ```
pub fn append_array_of_tables(
doc: Document,
key: List(String),
entries: List(#(List(String), Value)),
) -> Result(Document, EditError) {
use _ <- result.try(validate_edit_key(key))
use _ <- result.try(validate_table_entries(entries))
let Document(root: ast.Table(entries: doc_entries, header: header), ..) = doc
use <- bool.guard(
when: array_of_tables_key_conflicts(doc_entries, [], False, key),
return: Error(KeyConflict(key)),
)
use table_entries <- result.try(
list.try_map(entries, fn(entry) {
let #(path, value) = entry
use ast_value <- result.try(value_to_ast(value))
Ok(ast.KeyValue(
leading: ast.Trivia(""),
key: key_from_strings(path),
value: ast_value,
trailing: ast.Trivia("\n"),
))
}),
)
let new_entries =
list.append(
[
ast.TableHeader(ast.Header(
key: key_from_strings(key),
kind: ast.ArrayOfTablesHeader,
trivia: ast.Trivia(""),
)),
],
table_entries,
)
Ok(with_root(
doc,
ast.Table(entries: list.append(doc_entries, new_entries), header: header),
))
}
// Convert a public `Value` into its AST form.
//
// Structural table values cannot be written through the array/inline-table
// surface, so they are rejected here with `InvalidValue` rather than silently
// flattened. This is the single source of truth for that rule; `validate_value`
// enforces it earlier so callers get the error before any AST is built.
fn value_to_ast(value: Value) -> Result(ast.Value, EditError) {
case value {
StringValue(s) -> Ok(ast.String(s, ast.BasicString, basic_string_repr(s)))
IntValue(i) -> Ok(ast.Int(i, int.to_string(i)))
FloatValue(f) -> Ok(ast.Float(f, float.to_string(f)))
SpecialFloatValue(s) -> {
let #(internal, source_text) = case s {
PositiveInfinity -> #(ast.PositiveInfinity, "inf")
NegativeInfinity -> #(ast.NegativeInfinity, "-inf")
NotANumber -> #(ast.NotANumber, "nan")
}
Ok(ast.SpecialFloat(internal, source_text))
}
BoolValue(b) -> {
let repr = case b {
True -> "true"
False -> "false"
}
Ok(ast.Bool(b, repr))
}
DateValue(d) -> Ok(ast.Date(d.text))
TimeValue(t) -> Ok(ast.Time(t.text))
DateTimeValue(d) -> Ok(ast.DateTime(d.text))
ArrayValue(items) -> {
use ast_items <- result.try(list.try_map(items, value_to_array_item))
Ok(ast.Array(ast_items, emit_array_items(ast_items)))
}
InlineTableValue(entries) -> {
use ast_entries <- result.try(list.try_map(entries, value_to_inline_entry))
Ok(ast.InlineTable(ast_entries, emit_inline_table(ast_entries)))
}
StandardTableValue(_) | ArrayOfTablesValue(_) -> Error(InvalidValue)
}
}
fn value_to_array_item(value: Value) -> Result(ast.ArrayItem, EditError) {
use ast_value <- result.try(value_to_ast(value))
Ok(ast.ArrayItem(
leading: ast.Trivia(""),
value: ast_value,
trailing: ast.Trivia(""),
))
}
fn validate_values(values: List(Value)) -> Result(Nil, EditError) {
case values {
[] -> Ok(Nil)
[value, ..rest] ->
case validate_value(value) {
Error(error) -> Error(error)
Ok(Nil) -> validate_values(rest)
}
}
}
fn validate_value(value: Value) -> Result(Nil, EditError) {
case value {
StringValue(_)
| IntValue(_)
| FloatValue(_)
| SpecialFloatValue(_)
| BoolValue(_)
| DateValue(_)
| TimeValue(_)
| DateTimeValue(_) -> Ok(Nil)
ArrayValue(items) -> validate_values(items)
InlineTableValue(entries) -> validate_table_entries(entries)
StandardTableValue(_) | ArrayOfTablesValue(_) -> Error(InvalidValue)
}
}
fn value_to_inline_entry(
entry: #(List(String), Value),
) -> Result(ast.InlineTableEntry, EditError) {
let #(path, value) = entry
use ast_value <- result.try(value_to_ast(value))
Ok(ast.InlineTableEntry(
leading: ast.Trivia(""),
key: key_from_strings(path),
value: ast_value,
trailing: ast.Trivia(""),
))
}
fn emit_array_items(items: List(ast.ArrayItem)) -> String {
case items {
[] -> "[]"
_ ->
"["
<> {
items
|> list.map(fn(item) {
let ast.ArrayItem(leading: _, value: value, trailing: _) = item
emit_value(value)
})
|> string.join(with: ", ")
}
<> "]"
}
}
fn validate_table_entries(
entries: List(#(List(String), Value)),
) -> Result(Nil, EditError) {
validate_table_entries_loop(entries, [])
}
fn validate_table_entries_loop(
entries: List(#(List(String), Value)),
seen: List(List(String)),
) -> Result(Nil, EditError) {
case entries {
[] -> Ok(Nil)
[#(path, value), ..rest] ->
case validate_edit_key(path) {
Error(error) -> Error(error)
Ok(Nil) ->
case table_entry_conflicts(seen, path) {
True -> Error(KeyConflict(path))
False ->
case validate_value(value) {
Error(error) -> Error(error)
Ok(Nil) -> validate_table_entries_loop(rest, [path, ..seen])
}
}
}
}
}
fn table_entry_conflicts(seen: List(List(String)), path: List(String)) -> Bool {
case seen {
[] -> False
[existing, ..rest] ->
key_path_conflicts(existing, path) || table_entry_conflicts(rest, path)
}
}
fn entry_conflicts_with_target(
entry: ast.Entry,
active_table: List(String),
in_array_of_tables: Bool,
target: List(String),
) -> Bool {
case entry {
ast.TableHeader(ast.Header(key: key, kind: ast.StandardTable, trivia: _)) ->
key_to_strings(key) == target
ast.TableHeader(ast.Header(
key: key,
kind: ast.ArrayOfTablesHeader,
trivia: _,
)) -> {
let header_key = key_to_strings(key)
// Appending to an existing array of tables at the same path is the
// intended behavior; only flag prefix-overlapping AoT headers as
// conflicts.
header_key != target && key_path_conflicts(header_key, target)
}
ast.KeyValue(key: key, ..) ->
case in_array_of_tables {
// KeyValues inside an AoT instance are scoped to that instance
// and do not conflict with root-level paths.
True -> False
False -> {
let full_key = list.append(active_table, key_to_strings(key))
key_path_conflicts(full_key, target)
}
}
_ -> False
}
}
fn array_of_tables_key_conflicts(
entries: List(ast.Entry),
active_table: List(String),
in_array_of_tables: Bool,
target: List(String),
) -> Bool {
case entries {
[] -> False
[entry, ..rest] -> {
let #(next_active_table, next_in_aot) = case entry {
ast.TableHeader(ast.Header(key: key, kind: kind, trivia: _)) -> #(
key_to_strings(key),
kind == ast.ArrayOfTablesHeader,
)
_ -> #(active_table, in_array_of_tables)
}
let conflicts =
entry_conflicts_with_target(
entry,
active_table,
in_array_of_tables,
target,
)
conflicts
|| array_of_tables_key_conflicts(
rest,
next_active_table,
next_in_aot,
target,
)
}
}
}
/// Remove an existing value from a document.
///
/// Returns `MissingEditKey` when the key path does not exist, and
/// `EmptyKeyPath` when the key path is empty.
pub fn remove(doc: Document, key: List(String)) -> Result(Document, EditError) {
case validate_edit_key(key) {
Error(error) -> Error(error)
Ok(Nil) -> {
let Document(root: ast.Table(entries: entries, header: header), ..) = doc
let #(next_entries, removed) = remove_entries(entries, [], key)
case removed {
True ->
Ok(with_root(doc, ast.Table(entries: next_entries, header: header)))
False -> Error(MissingEditKey(key))
}
}
}
}
/// Insert a standalone comment before an existing key.
///
/// The comment text may include a leading `#`, but must not contain TOML
/// comment control characters. Returns `MissingEditKey` when the target key
/// does not exist, `InvalidCommentText` when the comment is unsafe to emit, and
/// `EmptyKeyPath` when the key path is empty.
///
/// ```gleam
/// let assert Ok(doc) = tomlet.parse("released = 1979-05-27\n")
/// let assert Ok(doc) =
/// tomlet.insert_comment_before(doc, ["released"], "release date")
/// tomlet.to_string(doc)
/// // -> "
/// // # release date
/// // released = 1979-05-27
/// // "
/// ```
pub fn insert_comment_before(
doc: Document,
key: List(String),
text: String,
) -> Result(Document, EditError) {
case validate_edit_key(key), validate_comment_text(text) {
Error(error), _ -> Error(error)
_, Error(error) -> Error(error)
Ok(Nil), Ok(Nil) -> {
let Document(root: ast.Table(entries: entries, header: header), ..) = doc
let #(updated_entries, inserted) =
insert_comment_before_entries(
entries,
key,
[],
ast.Comment(normalize_comment_text(text)),
)
case inserted {
True ->
Ok(with_root(doc, ast.Table(entries: updated_entries, header: header)))
False -> Error(MissingEditKey(key))
}
}
}
}
fn insert_comment_before_entries(
entries: List(ast.Entry),
target: List(String),
active_table: List(String),
comment: ast.Entry,
) -> #(List(ast.Entry), Bool) {
case entries {
[] -> #([], False)
[entry, ..rest] ->
case entry {
ast.TableHeader(header) -> {
let table_key = header_key(header)
use <- bool.guard(when: table_key == target, return: #(
[comment, entry, ..rest],
True,
))
let #(updated_rest, inserted) =
insert_comment_before_entries(rest, target, table_key, comment)
#([entry, ..updated_rest], inserted)
}
ast.KeyValue(key: entry_key, ..) -> {
let full_key = list.append(active_table, key_to_strings(entry_key))
use <- bool.guard(when: full_key == target, return: #(
[comment, entry, ..rest],
True,
))
let #(updated_rest, inserted) =
insert_comment_before_entries(rest, target, active_table, comment)
#([entry, ..updated_rest], inserted)
}
_ -> {
let #(updated_rest, inserted) =
insert_comment_before_entries(rest, target, active_table, comment)
#([entry, ..updated_rest], inserted)
}
}
}
}
fn normalize_comment_text(text: String) -> String {
let trimmed = string.trim(text)
case trimmed {
"" -> "#"
_ ->
case string.starts_with(trimmed, "#") {
True -> trimmed
False -> "# " <> trimmed
}
}
}
fn emit_table(table: ast.Table) -> String {
case table {
ast.Table(entries: [], header: _) -> ""
ast.Table(entries: entries, header: _) ->
entries
|> list.map(emit_entry)
|> string.join(with: "")
}
}
fn remove_entries(
entries: List(ast.Entry),
active_table: List(String),
target: List(String),
) -> #(List(ast.Entry), Bool) {
case entries {
[] -> #([], False)
[entry, ..rest] ->
case entry {
ast.TableHeader(ast.Header(key: key, ..)) -> {
let table_key = key_to_strings(key)
let #(next_rest, removed) = remove_entries(rest, table_key, target)
#([entry, ..next_rest], removed)
}
ast.KeyValue(key: key, ..) -> {
let full_key = list.append(active_table, key_to_strings(key))
let #(next_rest, removed) = remove_entries(rest, active_table, target)
case full_key == target {
True -> #(next_rest, True)
False -> #([entry, ..next_rest], removed)
}
}
_ -> {
let #(next_rest, removed) = remove_entries(rest, active_table, target)
#([entry, ..next_rest], removed)
}
}
}
}
fn emit_entry(entry: ast.Entry) -> String {
case entry {
ast.KeyValue(leading, key, value, trailing) ->
emit_trivia(leading)
<> emit_key(key)
<> " = "
<> emit_value(value)
<> emit_trivia(trailing)
ast.TableHeader(header) -> emit_header(header) <> "\n"
ast.Comment(text) -> text <> "\n"
ast.BlankLine -> "\n"
}
}
fn emit_header(header: ast.Header) -> String {
let ast.Header(key: key, kind: kind, trivia: _) = header
case kind {
ast.StandardTable -> "[" <> emit_key(key) <> "]"
ast.ArrayOfTablesHeader -> "[[" <> emit_key(key) <> "]]"
}
}
fn emit_key(key: ast.Key) -> String {
let ast.Key(segments) = key
segments
|> list.map(emit_key_segment)
|> string.join(with: ".")
}
fn emit_key_segment(segment: ast.KeySegment) -> String {
case segment {
ast.BareKeySegment(text) -> text
ast.QuotedKeySegment(_, source_text) -> source_text
}
}
fn emit_value(value: ast.Value) -> String {
case value {
ast.Int(_, source_text) -> source_text
ast.Float(_, source_text) -> source_text
ast.SpecialFloat(_, source_text) -> source_text
ast.Bool(_, source_text) -> source_text
ast.String(_, _, source_text) -> source_text
ast.Date(source_text) -> source_text
ast.Time(source_text) -> source_text
ast.DateTime(source_text) -> source_text
ast.Array(_, source_text) -> source_text
ast.InlineTable(_, source_text) -> source_text
ast.ArrayOfTables(items) ->
items
|> list.map(emit_table)
|> string.join(with: "")
}
}
fn emit_inline_table(entries: List(ast.InlineTableEntry)) -> String {
case entries {
[] -> "{}"
_ ->
"{ "
<> {
entries
|> list.map(emit_inline_table_entry)
|> string.join(with: ", ")
}
<> " }"
}
}
fn emit_inline_table_entry(entry: ast.InlineTableEntry) -> String {
let ast.InlineTableEntry(leading, key, value, trailing) = entry
emit_trivia(leading)
<> emit_key(key)
<> " = "
<> emit_value(value)
<> emit_trivia(trailing)
}
fn emit_trivia(trivia: ast.Trivia) -> String {
let ast.Trivia(text) = trivia
text
}
fn set_value(
doc: Document,
key: List(String),
value: ast.Value,
) -> Result(Document, EditError) {
use _ <- result.try(validate_edit_key(key))
let ast.Table(entries: entries, header: header) = doc.root
let #(updated_entries, found) =
update_existing_entries(entries, [], key, value)
use <- bool.guard(
when: found,
return: Ok(with_root(
doc,
ast.Table(entries: updated_entries, header: header),
)),
)
use <- bool.guard(
when: inline_table_blocks_key(entries, [], key),
return: Error(InlineTableInsertUnsupported(key)),
)
use <- bool.guard(
when: new_key_conflicts(entries, key),
return: Error(KeyConflict(key)),
)
use #(parent, leaf) <- result.try(result.replace_error(
parent_and_leaf(key),
EmptyKeyPath,
))
let appended_entries =
insert_appended_entry(updated_entries, key, parent, leaf, value)
Ok(with_root(doc, ast.Table(entries: appended_entries, header: header)))
}
fn update_key_value_entry(
entry: ast.Entry,
leading: ast.Trivia,
key: ast.Key,
entry_value: ast.Value,
trailing: ast.Trivia,
rest: List(ast.Entry),
active_table: List(String),
next_active_table: List(String),
target: List(String),
value: ast.Value,
) -> #(List(ast.Entry), Bool) {
let full_key = list.append(active_table, key_to_strings(key))
use <- bool.guard(when: full_key == target, return: #(
[ast.KeyValue(leading, key, value, trailing), ..rest],
True,
))
case entry_value {
ast.InlineTable(inline_entries, source_text: _) -> {
let #(updated_inline_entries, inline_found) =
update_inline_entries(inline_entries, full_key, target, value)
use <- bool.lazy_guard(when: !inline_found, return: fn() {
let #(updated_rest, found) =
update_existing_entries(rest, next_active_table, target, value)
#([entry, ..updated_rest], found)
})
let updated_value =
ast.InlineTable(
updated_inline_entries,
emit_inline_table(updated_inline_entries),
)
#([ast.KeyValue(leading, key, updated_value, trailing), ..rest], True)
}
_ -> {
let #(updated_rest, found) =
update_existing_entries(rest, next_active_table, target, value)
#([entry, ..updated_rest], found)
}
}
}
fn update_existing_entries(
entries: List(ast.Entry),
active_table: List(String),
target: List(String),
value: ast.Value,
) -> #(List(ast.Entry), Bool) {
case entries {
[] -> #([], False)
[entry, ..rest] -> {
let next_active_table = case entry {
ast.TableHeader(header) -> header_key(header)
_ -> active_table
}
case entry {
ast.KeyValue(
leading: leading,
key: key,
value: entry_value,
trailing: trailing,
) ->
update_key_value_entry(
entry,
leading,
key,
entry_value,
trailing,
rest,
active_table,
next_active_table,
target,
value,
)
_ -> {
let #(updated_rest, found) =
update_existing_entries(rest, next_active_table, target, value)
#([entry, ..updated_rest], found)
}
}
}
}
}
fn update_inline_entries(
entries: List(ast.InlineTableEntry),
active_path: List(String),
target: List(String),
value: ast.Value,
) -> #(List(ast.InlineTableEntry), Bool) {
case entries {
[] -> #([], False)
[
ast.InlineTableEntry(
leading: leading,
key: key,
value: entry_value,
trailing: trailing,
),
..rest
] ->
update_inline_entry(
leading,
key,
entry_value,
trailing,
rest,
active_path,
target,
value,
)
}
}
fn update_inline_entry(
leading: ast.Trivia,
key: ast.Key,
entry_value: ast.Value,
trailing: ast.Trivia,
rest: List(ast.InlineTableEntry),
active_path: List(String),
target: List(String),
value: ast.Value,
) -> #(List(ast.InlineTableEntry), Bool) {
let full_key = list.append(active_path, key_to_strings(key))
use <- bool.guard(when: full_key == target, return: #(
[ast.InlineTableEntry(leading, key, value, trailing), ..rest],
True,
))
case entry_value {
ast.InlineTable(nested_entries, source_text: _) -> {
let #(updated_nested_entries, nested_found) =
update_inline_entries(nested_entries, full_key, target, value)
use <- bool.lazy_guard(when: !nested_found, return: fn() {
let #(updated_rest, found) =
update_inline_entries(rest, active_path, target, value)
#(
[
ast.InlineTableEntry(leading, key, entry_value, trailing),
..updated_rest
],
found,
)
})
let updated_value =
ast.InlineTable(
updated_nested_entries,
emit_inline_table(updated_nested_entries),
)
#(
[ast.InlineTableEntry(leading, key, updated_value, trailing), ..rest],
True,
)
}
_ -> {
let #(updated_rest, found) =
update_inline_entries(rest, active_path, target, value)
#(
[
ast.InlineTableEntry(leading, key, entry_value, trailing),
..updated_rest
],
found,
)
}
}
}
// Append a brand-new key/value, choosing between a `[parent]` header and a
// dotted key. When `parent` is a table that exists only because it was defined
// by dotted keys, synthesizing a `[parent]` header would be rejected by the
// parser (a dotted-defined table cannot be re-opened with a header), so the new
// key is emitted as a dotted key under the nearest enclosing explicit table.
fn insert_appended_entry(
entries: List(ast.Entry),
key: List(String),
parent: List(String),
leaf: String,
value: ast.Value,
) -> List(ast.Entry) {
let dotted_anchor = case parent {
[] -> Error(Nil)
_ -> dotted_key_context(entries, [], parent)
}
case dotted_anchor {
Ok(anchor) -> {
let relative = list.drop(key, list.length(anchor))
append_new_entry(entries, anchor, new_dotted_key_value(relative, value))
}
Error(Nil) -> append_new_entry(entries, parent, new_key_value(leaf, value))
}
}
// When `parent` is realized only through dotted keys (no explicit standard-table
// header), return the table context (`active_table`) the dotted family lives in
// so the new key can be appended there as a dotted key.
fn dotted_key_context(
entries: List(ast.Entry),
active_table: List(String),
parent: List(String),
) -> Result(List(String), Nil) {
case entries {
[] -> Error(Nil)
[entry, ..rest] -> {
let next_active_table = case entry {
ast.TableHeader(header) -> header_key(header)
_ -> active_table
}
case entry {
ast.KeyValue(key: key, ..) -> {
let full_key = list.append(active_table, key_to_strings(key))
use <- bool.guard(
when: key_utils.starts_with(full_key, parent) && full_key != parent,
return: Ok(active_table),
)
dotted_key_context(rest, next_active_table, parent)
}
_ -> dotted_key_context(rest, next_active_table, parent)
}
}
}
}
fn append_new_entry(
entries: List(ast.Entry),
parent: List(String),
new_entry: ast.Entry,
) -> List(ast.Entry) {
case parent {
[] -> append_root_entry(entries, new_entry)
_ -> {
let #(updated_entries, found) =
append_table_entry(entries, parent, new_entry)
case found {
True -> updated_entries
False -> list.append(entries, [new_table_header(parent), new_entry])
}
}
}
}
fn append_root_entry(
entries: List(ast.Entry),
new_entry: ast.Entry,
) -> List(ast.Entry) {
case entries {
[] -> [new_entry]
[entry, ..rest] ->
case entry {
ast.TableHeader(_) -> [new_entry, entry, ..rest]
_ -> [entry, ..append_root_entry(rest, new_entry)]
}
}
}
fn append_table_entry(
entries: List(ast.Entry),
parent: List(String),
new_entry: ast.Entry,
) -> #(List(ast.Entry), Bool) {
case entries {
[] -> #([], False)
[entry, ..rest] ->
case entry {
ast.TableHeader(header) ->
case
header_key(header) == parent && header_is_standard_table(header)
{
True -> {
let #(updated_rest, found) = append_inside_table(rest, new_entry)
#([entry, ..updated_rest], found)
}
False -> {
let #(updated_rest, found) =
append_table_entry(rest, parent, new_entry)
#([entry, ..updated_rest], found)
}
}
_ -> {
let #(updated_rest, found) =
append_table_entry(rest, parent, new_entry)
#([entry, ..updated_rest], found)
}
}
}
}
fn append_inside_table(
entries: List(ast.Entry),
new_entry: ast.Entry,
) -> #(List(ast.Entry), Bool) {
case entries {
[] -> #([new_entry], True)
[entry, ..rest] ->
case entry {
ast.TableHeader(_) -> #([new_entry, entry, ..rest], True)
_ -> {
let #(updated_rest, found) = append_inside_table(rest, new_entry)
#([entry, ..updated_rest], found)
}
}
}
}
fn new_key_conflicts(entries: List(ast.Entry), target: List(String)) -> Bool {
new_key_conflicts_with_table(entries, [], target)
}
fn inline_table_blocks_key(
entries: List(ast.Entry),
active_table: List(String),
target: List(String),
) -> Bool {
case entries {
[] -> False
[entry, ..rest] -> {
let next_active_table = case entry {
ast.TableHeader(header) -> header_key(header)
_ -> active_table
}
let blocks = case entry {
ast.KeyValue(key: key, value: ast.InlineTable(..), ..) -> {
let full_key = list.append(active_table, key_to_strings(key))
key_utils.starts_with(target, full_key) && full_key != target
}
_ -> False
}
blocks || inline_table_blocks_key(rest, next_active_table, target)
}
}
}
fn new_key_conflicts_with_table(
entries: List(ast.Entry),
active_table: List(String),
target: List(String),
) -> Bool {
case entries {
[] -> False
[entry, ..rest] -> {
let next_active_table = case entry {
ast.TableHeader(header) -> header_key(header)
_ -> active_table
}
case entry {
ast.TableHeader(header) ->
header_conflicts_with_new_key(header, target)
|| new_key_conflicts_with_table(rest, next_active_table, target)
ast.KeyValue(key: key, ..) -> {
let full_key = list.append(active_table, key_to_strings(key))
key_path_conflicts(full_key, target)
|| new_key_conflicts_with_table(rest, next_active_table, target)
}
_ -> new_key_conflicts_with_table(rest, next_active_table, target)
}
}
}
}
fn header_conflicts_with_new_key(
header: ast.Header,
target: List(String),
) -> Bool {
let ast.Header(kind: kind, ..) = header
let key = header_key(header)
case kind {
ast.StandardTable -> target == key || key_utils.starts_with(key, target)
ast.ArrayOfTablesHeader -> key_path_conflicts(key, target)
}
}
fn key_path_conflicts(existing: List(String), target: List(String)) -> Bool {
key_utils.conflicts(existing, target)
}
fn new_key_value(key: String, value: ast.Value) -> ast.Entry {
ast.KeyValue(
leading: ast.Trivia(""),
key: ast.Key([key_segment_from_string(key)]),
value: value,
trailing: ast.Trivia("\n"),
)
}
fn new_dotted_key_value(path: List(String), value: ast.Value) -> ast.Entry {
ast.KeyValue(
leading: ast.Trivia(""),
key: key_from_strings(path),
value: value,
trailing: ast.Trivia("\n"),
)
}
fn new_table_header(key: List(String)) -> ast.Entry {
ast.TableHeader(ast.Header(
key: key_from_strings(key),
kind: ast.StandardTable,
trivia: ast.Trivia(""),
))
}
fn parent_and_leaf(key: List(String)) -> Result(#(List(String), String), Nil) {
case key {
[] -> Error(Nil)
[leaf] -> Ok(#([], leaf))
[segment, ..rest] -> {
case parent_and_leaf(rest) {
Ok(#(parent, leaf)) -> Ok(#([segment, ..parent], leaf))
Error(Nil) -> Error(Nil)
}
}
}
}
fn header_key(header: ast.Header) -> List(String) {
let ast.Header(key: key, kind: _, trivia: _) = header
key_to_strings(key)
}
fn header_is_standard_table(header: ast.Header) -> Bool {
let ast.Header(kind: kind, ..) = header
kind == ast.StandardTable
}
fn key_to_strings(key: ast.Key) -> List(String) {
key_utils.to_strings(key)
}
fn key_from_strings(segments: List(String)) -> ast.Key {
ast.Key(list.map(segments, key_segment_from_string))
}
fn key_segment_from_string(segment: String) -> ast.KeySegment {
case key_utils.is_bare_key(segment) {
True -> ast.BareKeySegment(segment)
False -> ast.QuotedKeySegment(segment, basic_string_repr(segment))
}
}
fn validate_edit_key(key: List(String)) -> Result(Nil, EditError) {
case key {
[] -> Error(EmptyKeyPath)
_ -> validate_key_segments(key)
}
}
fn validate_key_segments(key: List(String)) -> Result(Nil, EditError) {
case key {
[] -> Ok(Nil)
[segment, ..rest] ->
case string.contains(segment, "\n") || string.contains(segment, "\r") {
True -> Error(InvalidKeySegment(segment))
False -> validate_key_segments(rest)
}
}
}
fn validate_comment_text(text: String) -> Result(Nil, EditError) {
text
|> string.to_utf_codepoints
|> validate_comment_codepoints
}
fn validate_comment_codepoints(
codepoints: List(UtfCodepoint),
) -> Result(Nil, EditError) {
case codepoints {
[] -> Ok(Nil)
[codepoint, ..rest] -> {
let value = string.utf_codepoint_to_int(codepoint)
case { value <= 8 } || { value >= 10 && value <= 31 } || value == 127 {
True -> Error(InvalidCommentText)
False -> validate_comment_codepoints(rest)
}
}
}
}
fn basic_string_repr(value: String) -> String {
"\"" <> escape_basic_string(value) <> "\""
}
fn escape_basic_string(value: String) -> String {
value
|> string.to_utf_codepoints
|> escape_basic_string_codepoints
}
fn escape_basic_string_codepoints(codepoints: List(UtfCodepoint)) -> String {
case codepoints {
[] -> ""
[codepoint, ..rest] -> {
let codepoint_int = string.utf_codepoint_to_int(codepoint)
let escaped = case codepoint_int {
8 -> "\\b"
9 -> "\\t"
10 -> "\\n"
12 -> "\\f"
13 -> "\\r"
34 -> "\\\""
92 -> "\\\\"
i if i < 32 || i == 127 -> "\\u" <> padded_hex(i)
_ -> string.from_utf_codepoints([codepoint])
}
escaped <> escape_basic_string_codepoints(rest)
}
}
}
fn padded_hex(value: Int) -> String {
// Total: `to_base_string` only fails on an invalid base, and 16 is valid.
// The assert cannot fail; a fallback string would be a silent error instead.
// nolint: assert_ok_pattern
let assert Ok(hex) = int.to_base_string(value, 16)
case string.length(hex) {
1 -> "000" <> hex
2 -> "00" <> hex
3 -> "0" <> hex
_ -> hex
}
}