Skip to main content

src/molt.gleam

//// molt: TOML manipulation kit
////
//// Molt parses a TOML document into a concrete syntax representation that
//// can be transformed and rewritten while preserving comments associated with
//// nodes.
////
//// ## Path Syntax
////
//// Molt functions and operations accept a path string made up of key
//// segments separated by `.` (like `a.b`) and array indexes in brackets
//// (like `[0]` or `[-1]`).
////
//// Bare key segments may contain ASCII letters, digits, underscores, and
//// dashes. Segments requiring other characters (such as spaces or dots) must
//// be quoted with single quotes (no escape processing) or double quotes (with
//// escape processing, e.g. `\"`).
////
//// ```
//// a.b.c.d                   // ["a", "b", "c", "d"]
//// a.b.-1.d                  // ["a", "b", "-1", "d"]
//// a.b."with space".'[3]'    // ["a", "b", "with space", "[3]"]
//// a.b."\e".'\e'             // ["a", "b", "\u{001b}", "\\e"]
//// ```
////
//// Array indexes must be integer values within brackets and negative indexing
//// is supported. `0` is the first value in an array or array of tables, `-1` is
//// the last value in an array or array of tables.
////
//// ```
//// a.b[-1].c                 // ["a", "b", -1, "c"]
//// a.b[3].c                  // ["a", "b", 3, "c"]
//// ```
////
//// Path resolution behaviour with key or index values that do not resolve to
//// a logical entry in the document depends on the operation performed.
////
//// When operations are required against the document root, use an empty path
//// string (`""`).
////
//// The `InvalidPath` variant of `MoltError` will be returned from
//// `molt` functions if the path syntax is invalid.
////
//// In some contexts, _only_ key paths may be provided.

import gleam/bit_array
import gleam/bool
import gleam/dict
import gleam/list
import gleam/option.{None, Some}
import gleam/result
import greenwood
import molt/cst
import molt/error.{type MoltError}
import molt/internal/document
import molt/internal/document/comments
import molt/internal/document/index
import molt/internal/emitter
import molt/internal/parser
import molt/internal/path as path_
import molt/internal/utils
import molt/internal/validate
import molt/ops.{type Operation}
import molt/types.{
  type Document, type DocumentIndex, type Path as Path, type PathSegment,
  type TomlKind, Document, IndexSegment, KeySegment,
}
import molt/value.{type Value}

/// TOML 1.0
pub const v1_0 = types.v1_0

/// TOML 1.1
pub const v1_1 = types.v1_1

/// Create an empty TOML document.
pub fn new() -> Document {
  Document(
    tree: greenwood.node(types.Root, []),
    version: v1_1,
    index: Some(dict.new()),
    error_count: 0,
  )
}

/// Parse a TOML source string into a `Document`.
///
/// Returns an error if the the document _cannot_ be parsed. Note that most
/// documents can be parsed, but not all documents with errors produce usable or
/// easily recoverable syntax trees.
///
/// The document's `error_count` records how many validation errors were found;
/// retrieve the full positioned list with `document_errors`.
///
/// The parsed document defaults to TOML 1.1 format, even if the source file was
/// TOML 1.0.
pub fn parse(source: String) -> Result(Document, MoltError) {
  use tree <- result.try(parser.parse(source))

  let error_count = validate.count(tree)

  Ok(Document(tree:, version: v1_1, index: None, error_count:))
}

/// Parses a TOML source `BitArray` into a `Document`.
///
/// Returns an error if the source is not valid UTF-8 data or if the transformed
/// UTF-8 data fails on `parse`.
pub fn parse_bits(source: BitArray) -> Result(Document, MoltError) {
  use source <- result.try(safe_bits_to_string(source))
  parse(source)
}

/// Every validation error in the document as a fully-positioned `SyntaxError`,
/// computed on demand from the current tree. Returns `[]` for a valid document.
///
/// This is the only path that builds error spans; parsing and construction only
/// count, so positions are paid for solely when you ask for them here.
pub fn document_errors(doc: Document) -> List(types.SyntaxError) {
  validate.enrich(doc.tree)
}

/// Whether the document has any validation errors. Most document-level
/// operations refuse to run while this is `True`.
pub fn has_errors(doc: Document) -> Bool {
  doc.error_count > 0
}

/// The number of validation errors found in the document.
pub fn error_count(doc: Document) -> Int {
  doc.error_count
}

/// Generate a TOML string from the document.
///
/// If the document version is the same as the original version and no changes
/// have been made, the original document will be reproduced exactly.
pub fn to_string(doc: Document) -> String {
  emitter.emit_versioned(doc.tree, doc.version)
}

/// Outputs a `normalize`d version of the document as a string.
pub fn to_normalized_string(doc doc: Document) -> String {
  doc |> normalize |> to_string
}

/// Returns a copy of the document with its tree normalized:
///
/// - Unix newlines (LF) throughout.
/// - Table header declarations have excess leading and interior space removed.
/// - Key/value pairs have excess leading space removed, a single space after
///   the key and a single space before the value, resulting in `key = value`.
/// - A single blank line separates table and array of tables headers.
/// - Leading comments are preserved immediately before their node.
/// - Trailing (inline) comments on key-value and header lines are preserved.
/// - Inline arrays and tables without comments are collapsed to a single-line
///   form.
/// - The document ends with a single trailing newline.
///
/// The returned document is valid for further operations or for piping into
/// `to_string`.
pub fn normalize(doc doc: Document) -> Document {
  Document(..doc, tree: emitter.normalize(doc.tree))
}

/// Execute a batch of `Operation`s over a `Document` where all operations must
/// succeed for an update.
///
/// `Operation`s will not run over a `Document` with errors.
pub fn run(
  doc doc: Document,
  ops ops: List(Operation),
) -> Result(Document, MoltError) {
  list.try_fold(ops, doc, document.run)
}

/// Appends to an array at `path`.
///
/// `path` must resolve to either a value node containing an array or an array
/// of tables. When `path` resolves to an array of tables, the `value`
/// provided _must_ be table-like.
pub fn append(
  doc doc: Document,
  path path: String,
  value value: Value,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Append(path:, value:)])
}

/// Concatenate multiple values to an array at `path`.
///
/// `path` must resolve to either a value node containing an array or an array
/// of tables. When `path` resolves to an array of tables, all entries in
/// `values` _must_ be table-like.
pub fn concat(
  doc doc: Document,
  path path: String,
  values values: List(Value),
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Concat(path:, values:)])
}

/// Ensures that a table or array of tables exists at `path`.
///
/// `kind` must be `types.Table` or `types.ArrayOfTables`; other kinds are
/// rejected.
///
/// If the matching structure already exists, nothing is done. If `path` does
/// not resolve, an empty entry is created with implicit table ancestors as
/// required. If `path` resolves to an implicit table and `kind` is
/// `types.Table`, the implicit table is concretized into an emitted header
/// using the key segments of `path`.
///
/// A `TypeMismatch` error is returned if `path` resolves to any other value
/// shape, or if any ancestor in the `path` is anything other than an implicit
/// table, concrete table, or array of tables entry.
pub fn ensure_exists(
  doc doc: Document,
  path path: String,
  kind kind: TomlKind,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.EnsureExists(path:, kind:)])
}

/// Get the value or structure at `path` as a molt Value.
///
/// Implicit tables are returned as table values.
pub fn get(doc doc: Document, path path: String) -> Result(Value, MoltError) {
  use path <- result.try(path_.parse(path))

  case path {
    [] -> get_root_as_value(doc:)
    _ -> {
      use idx <- index.with_index(doc)
      get_value_by_index(doc:, idx:, path:)
    }
  }
}

/// Reads the comments attached to the node at `path`.
///
/// `path` must resolve to a concrete node (not an implicit table) or the root
/// of the document (`""`); other paths return an error.
///
/// The result mirrors `set_comments`: leading comment lines and an optional
/// trailing (inline) comment. Comment text is returned verbatim, including the
/// leading `#`, so a value read here round-trips back through `set_comments`
/// unchanged.
pub fn get_comments(
  doc doc: Document,
  path path: String,
) -> Result(ops.Comments, MoltError) {
  comments.get_comments(doc:, path:)
}

pub type DocumentCommentPosition {
  /// Document header comments are stored in this position before any value
  /// node. When rendered, a blank line will be placed between these comments
  /// and the first value node.
  Header
  /// Document trailer comments are stored in this position after all value
  /// nodes. When rendered, a newline _may_ be placed between the final value
  /// node and these comments.
  Trailer
}

/// Reads `Header` or `Trailer` document comments.
///
/// Returns the comment lines verbatim (including the leading `#` but without
/// newlines) or `[]` when there are no comments.
pub fn get_document_comments(
  doc doc: Document,
  at at: DocumentCommentPosition,
) -> List(String) {
  case at {
    Header -> cst.document_head_comments(doc.tree)
    Trailer -> cst.document_tail_comments(doc.tree)
  }
}

/// Replaces `Header` or `Trailer` document comments, returning the updated
/// document. If the comment text does not include `#`, the comment text will
/// have `# ` prepended. Comments must not include newlines; they will be added
/// appropriately on emit.
///
/// Passing `[]` clears the comments.
pub fn set_document_comments(
  doc doc: Document,
  at at: DocumentCommentPosition,
  comments comments: List(String),
) -> Document {
  let tree = case at {
    Header -> cst.set_document_head_comments(doc.tree, comments)
    Trailer -> cst.set_document_tail_comments(doc.tree, comments)
  }
  // Trivia-only changes don't move any path, but materializing/dropping the
  // PostScript child reshapes Root's children, so invalidate the cached index.
  Document(..doc, tree:, index: None)
}

/// Check if `path` exists in the document.
pub fn has(doc doc: Document, path path: String) -> Bool {
  case path_.parse(path), index.get_index(doc) {
    Ok([]), _ -> True
    Ok(path), Ok(idx) -> {
      let lookup = index.resolve_negative_indices(idx:, path:)
      let key = index.path_to_index_key(lookup)
      use <- bool.guard(index.has_key(idx, key), return: True)

      case index.find_deepest_ancestor_entry(idx, key) {
        Ok(types.IndexScalarValue(..))
        | Ok(types.IndexArrayValue(..))
        | Ok(types.IndexInlineTableValue(..)) ->
          cst.get(node: doc.tree, path: lookup)
          |> result.is_ok
        _ -> False
      }
    }
    _, _ -> False
  }
}

/// Inserts a value before index `before` in an array at `path`.
///
/// `path` must resolve to a value node containing an array or an array of
/// tables. When `path` resolves to an array of tables, `value` must be
/// table-like.
pub fn insert(
  doc doc: Document,
  path path: String,
  before before: Int,
  value value: Value,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Insert(path:, before:, value:)])
}

/// Inserts a key/value pair before an existing key in the table at `path`.
///
/// `path` must resolve to a concrete table, array of tables entry, or
/// implicit table. Both `before` and `key` are literal key names (immediate
/// children of `path`). If `before` is not found, the entry is appended.
///
/// When `path` is an implicit table, the new entry is emitted as a
/// root-level dotted key.
pub fn insert_key(
  doc doc: Document,
  path path: String,
  before before: String,
  key key: String,
  value value: Value,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.InsertKey(path:, before:, key:, value:)])
}

/// Return the list of keys in a table-like value at the provided `path`.
pub fn keys(
  doc doc: Document,
  path p: String,
) -> Result(List(String), MoltError) {
  use segments <- result.try(path_.parse(p))

  use idx <- index.with_index(doc)

  case segments {
    [] -> Ok(index.root_children(idx))
    _ ->
      case index.get_path(idx, segments) {
        Ok(types.IndexTable(children:))
        | Ok(types.IndexImplicitTable(children:))
        | Ok(types.IndexArrayOfTablesEntry(children:, ..)) -> Ok(children)

        Ok(_) ->
          Error(error.TypeMismatch(
            path: Some(path_.to_string(segments)),
            expected: "table, implicit table, or array of tables entry",
            got: "value",
          ))
        Error(Nil) -> Error(error.not_found_path(segments))
      }
  }
}

/// Return the number of entries in an array or array of tables at `path`.
pub fn length(doc doc: Document, path p: String) -> Result(Int, MoltError) {
  use segments <- result.try(path_.parse(p))

  use <- bool.guard(
    segments == [],
    return: Error(error.InvalidOperation("length", None)),
  )

  use idx <- index.with_index(doc)

  case index.get_path(index: idx, path: segments) {
    Ok(types.IndexArrayOfTables(count:, ..)) -> Ok(count)
    Ok(types.IndexArrayValue(..)) -> {
      let #(table_segs, key) = path_.split_last_segment(segments)

      use key_name <- path_.with_segment_key_name(
        key,
        or: Error(error.not_found_path(segments)),
      )
      use val <- result.try(document.get_value(
        doc:,
        at: table_segs,
        key: key_name,
      ))

      value.array_length(val)
      |> option.to_result(error.TypeMismatch(
        path: Some(path_.to_string(segments)),
        expected: "array",
        got: value.type_of(val),
      ))
    }
    Ok(entry) ->
      Error(error.TypeMismatch(
        path: Some(path_.to_string(segments)),
        expected: "array",
        got: utils.index_entry_to_string(entry),
      ))

    Error(Nil) ->
      case path_.split_last_segment(segments) {
        #(parent, IndexSegment(i)) ->
          case index.get_path(index: idx, path: parent) {
            Ok(types.IndexArrayValue(..)) -> {
              let #(table_segs, key) = path_.split_last_segment(parent)
              use key_name <- path_.with_segment_key_name(
                key,
                or: Error(error.not_found_path(segments)),
              )
              use arr <- result.try(document.get_value(
                doc:,
                at: table_segs,
                key: key_name,
              ))
              use items <- result.try(
                value.array_to_list(arr)
                |> result.replace_error(error.not_found_path(segments)),
              )
              let len = list.length(items)
              let resolved = utils.resolve_index(i, len)

              use entry <- result.try(
                utils.list_at(items, resolved)
                |> result.replace_error(error.IndexOutOfRange(
                  path: path_.to_string(parent),
                  index: i,
                  length: len,
                )),
              )

              value.array_length(entry)
              |> option.to_result(error.TypeMismatch(
                path: Some(path_.to_string(segments)),
                expected: "array",
                got: value.type_of(entry),
              ))
            }
            _ -> Error(error.not_found_path(segments))
          }
        _ -> Error(error.not_found_path(segments))
      }
  }
}

/// Merge key/value entries into the resolved table at `path`.
///
/// `path` must resolve to a concrete table or an array of tables entry;
/// implicit tables and other shapes are rejected with a `TypeMismatch` error.
///
/// Each key in `entries` is parsed as a path value relative to the table at
/// `path`. Index segments in entry keys will be rejected with an
/// `InvalidPath` error, and entry keys redefining any existing concrete table
/// or value are rejected.
///
/// The `on_conflict` parameter controls how collisions with existing leaf
/// keys are resolved.
pub fn merge_values(
  doc doc: Document,
  path path: String,
  entries entries: List(#(String, Value)),
  on_conflict on_conflict: ops.ConflictStrategy,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.MergeValues(path:, entries:, on_conflict:)])
}

/// Moves the node at `from` to `to`.
///
/// `from` must resolve to an existing node of any kind except the root. `to`
/// must not already exist, and its last segment must be a key (not an index).
/// The node is removed from `from` and re-inserted at `to`, preserving its
/// structure.
pub fn move(
  doc doc: Document,
  from from: String,
  to to: String,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Move(from:, to:)])
}

/// Moves comments from the node at `from` to the node at `to`.
///
/// Both `from` and `to` must resolve to concrete nodes or the root of the
/// document (`""`).
pub fn move_comments(
  doc doc: Document,
  from from: String,
  to to: String,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.MoveComments(from:, to:)])
}

/// Moves a subset of keys from the table at `from` into the table at `to`.
///
/// `from` must resolve to a concrete table, implicit table, or array of
/// tables entry. `keys` are literal key names naming the immediate children
/// of the `from` table. Keys not present in the `from` table are ignored.
///
/// If `to` does not exist or is an implicit table, a concrete table header
/// will be created. The `on_conflict` parameter controls how collisions
/// with existing keys in the `to` table are resolved.
pub fn move_keys(
  doc doc: Document,
  from from: String,
  to to: String,
  keys keys: List(String),
  on_conflict on_conflict: ops.ConflictStrategy,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.MoveKeys(from:, to:, keys:, on_conflict:)])
}

/// Unconditionally places `value` at `path`.
///
/// If `path` already exists, it is removed before writing the `value`.
/// Structural `Value`s (table, array of tables, etc.) are permitted.
pub fn place(
  doc doc: Document,
  path path: String,
  value value: Value,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Place(path:, value:)])
}

/// Removes the node at `path` from the document.
///
/// If `path` resolves to an implicit table, the implicit table and _all_
/// concrete nodes beneath it are removed.
pub fn remove(
  doc doc: Document,
  path path: String,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Remove(path:)])
}

/// Renames the last segment of `path` to `to`.
///
/// The last segment of `path` must be a key. `to` is a literal key name and
/// must not already exist as a sibling.
///
/// Renaming an implicit table renames all concrete descendants that reference
/// it.
pub fn rename(
  doc doc: Document,
  path path: String,
  to to: String,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Rename(path:, to:)])
}

/// Converts the structure at `path` between inline and block forms.
///
/// `path` must resolve to a table or array of tables. The data is preserved;
/// only the representation changes (inline table ↔ table section, array of
/// inline tables ↔ array of tables entries).
///
/// A `path` that does not reference a convertible structure is
/// a `TypeMismatch`. Conversions that would produce invalid TOML (e.g.,
/// inlining a table with sub-table descendants) are rejected.
pub fn representation(
  doc doc: Document,
  path path: String,
  form form: ops.Form,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Representation(path:, form:)])
}

/// Sets a value at `path` in the document.
///
/// Creates or overwrites a key/value node. If `path` does not exist, it is
/// created (with implicit ancestors as needed). If `path` resolves to an
/// existing value node, the value is replaced.
///
/// If `path` resolves to a structural node (table sections, array of tables,
/// implicit tables), `Set` will return a `TypeMismatch` error.
pub fn set(
  doc doc: Document,
  path path: String,
  value value: Value,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Set(path:, value:)])
}

/// Sets comments on the node at `path`.
///
/// `path` must resolve to a concrete node (not an implicit table) or the root
/// of the document (`""`). Replaces any existing comments on the node with the
/// provided `comments`.
pub fn set_comments(
  doc doc: Document,
  path path: String,
  comments comments: ops.Comments,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.SetComments(path:, comments:)])
}

/// Set the target TOML version for output.
///
/// Parsed documents default to TOML 1.1 (`molt.v1_1`).
pub fn set_version(
  doc doc: Document,
  to version: types.TomlVersion,
) -> Document {
  Document(..doc, version:)
}

/// Transfers all keys from `from` to `to`, then removes `from`.
///
/// `from` must resolve to a concrete or implicit table. `to` will be
/// created as a concrete table if it does not exist. The `on_conflict`
/// parameter controls how collisions with existing keys in `to` are
/// resolved.
pub fn transfer(
  doc doc: Document,
  from from: String,
  to to: String,
  on_conflict on_conflict: ops.ConflictStrategy,
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Transfer(from:, to:, on_conflict:)])
}

/// Transforms a value in place via callback.
///
/// `path` must resolve to a scalar, array, or inline table value node.
/// Structural types (concrete tables, implicit tables, array of tables) are
/// rejected with `TypeMismatch`.
///
/// Transforming inline tables or arrays round-trips through `Value`, which
/// loses internal comments and multiline formatting.
pub fn update(
  doc doc: Document,
  path path: String,
  with with: fn(Value) -> Result(Value, MoltError),
) -> Result(Document, MoltError) {
  run(doc:, ops: [ops.Update(path:, with:)])
}

/// Creates a `MoltError` for returning from `Update` callbacks.
///
/// `Update` callbacks must return `Result(Value, MoltError)`. Use this
/// function to signal a failure with a descriptive message.
pub fn update_error(message: String) -> MoltError {
  error.UpdateError(message:)
}

fn get_root_as_value(doc doc: Document) -> Result(Value, MoltError) {
  document.list_keys(doc:, at: [])
  |> result.map(
    list.filter_map(_, fn(k) {
      document.get_value(doc:, at: [], key: k)
      |> result.map(fn(v) { #(k, v) })
      |> result.replace_error(Error(Nil))
    }),
  )
  |> result.unwrap([])
  |> value.from_table_entries
  |> Ok
}

fn get_array_of_tables_entry_value(
  doc doc: Document,
  at segments: Path,
  index index: Int,
) -> Result(Value, MoltError) {
  let entry_path = list.append(segments, [IndexSegment(index)])
  case cst.get(node: doc.tree, path: entry_path) {
    Ok(entry_node) -> {
      let entries =
        cst.list_keys(entry_node)
        |> list.filter_map(fn(k) {
          case cst.get(node: entry_node, path: [KeySegment(k)]) {
            Ok(kv) -> Ok(#(k, value.from_cst(kv)))
            Error(_) -> Error(Nil)
          }
        })
      Ok(value.from_table_entries(entries))
    }
    _ -> Error(error.not_found_path(segments))
  }
}

fn get_array_of_tables_as_value(
  doc doc: Document,
  at segments: Path,
  count count: Int,
) -> Result(Value, MoltError) {
  get_array_of_table_entry(doc:, path: segments, index: 0, count:, acc: [])
  |> value.from_array_of_tables()
}

fn get_array_of_table_entry(
  doc doc: Document,
  path segments: List(PathSegment),
  index index: Int,
  count count: Int,
  acc acc: List(Value),
) -> List(Value) {
  case index >= count {
    True -> list.reverse(acc)
    False ->
      case get_array_of_tables_entry_value(doc:, at: segments, index:) {
        Ok(v) ->
          get_array_of_table_entry(
            doc:,
            path: segments,
            index: index + 1,
            count:,
            acc: [v, ..acc],
          )
        _ ->
          get_array_of_table_entry(
            doc:,
            path: segments,
            index: index + 1,
            count:,
            acc:,
          )
      }
  }
}

fn safe_bits_to_string(source: BitArray) -> Result(String, MoltError) {
  // We save up to two leading BOMs because `bit_array.to_string` goes through
  // a text decoder on JavaScript targets which strips a leading BOM and there
  // are two invalid cases in the TOML suite which fail if we don't preserve
  // this exactly. We don't need more than two for those cases to fail.
  let #(bom_prefix, source) = case source {
    <<239, 187, 191, 239, 187, 191, rest:bytes>> -> #("\u{FEFF}\u{FEFF}", rest)
    <<239, 187, 191, rest:bytes>> -> #("\u{FEFF}", rest)
    _ -> #("", source)
  }

  case bit_array.to_string(source) {
    Ok(source) -> Ok(bom_prefix <> source)
    Error(Nil) -> Error(error.InvalidSourceEncoding)
  }
}

fn get_value_by_index(
  doc doc: Document,
  idx idx: DocumentIndex,
  path segments: Path,
) -> Result(Value, MoltError) {
  // Resolve negative AoT indices to positive for lookup/navigation; `segments`
  // (the caller's original path) is retained for error reporting.
  let lookup = index.resolve_negative_indices(idx:, path: segments)
  let key = index.path_to_index_key(lookup)
  case index.get(idx, key) {
    Ok(types.IndexScalarValue(container:))
    | Ok(types.IndexArrayValue(container:))
    | Ok(types.IndexInlineTableValue(container:)) -> {
      let kv_key = list.drop(lookup, list.length(container))
      case kv_key {
        [KeySegment(_), ..] -> document.get_value_at(doc:, container:, kv_key:)
        _ -> Error(error.not_found_path(segments))
      }
    }
    Ok(types.IndexImplicitTable(children:)) -> {
      let entries =
        list.filter_map(children, fn(k) {
          get_value_by_index(
            doc:,
            idx:,
            path: list.append(lookup, [KeySegment(k)]),
          )
          |> result.map(fn(v) { #(k, v) })
          |> result.replace_error(Nil)
        })
      Ok(value.from_table_entries(entries))
    }
    Ok(types.IndexTable(..)) | Ok(types.IndexArrayOfTablesEntry(..)) -> {
      use node <- result.try(
        cst.get(node: doc.tree, path: lookup)
        |> result.replace_error(error.not_found_path(segments)),
      )

      let entries =
        cst.list_keys(node)
        |> list.filter_map(fn(k) {
          cst.get(node: node, path: [KeySegment(k)])
          |> result.map(fn(kv) { #(k, value.from_cst(kv)) })
          |> result.replace_error(Nil)
        })

      Ok(value.from_table_entries(entries))
    }
    Ok(types.IndexArrayOfTables(count:, ..)) ->
      get_array_of_tables_as_value(doc:, at: lookup, count:)
    Error(Nil) ->
      // Miss: might be descending into a value (including a negative array
      // index, which structural navigation resolves).
      case index.find_deepest_ancestor_entry(idx, key) {
        Ok(types.IndexScalarValue(..))
        | Ok(types.IndexArrayValue(..))
        | Ok(types.IndexInlineTableValue(..)) ->
          cst.get(node: doc.tree, path: lookup)
          |> result.map(value.from_cst)
          |> result.replace_error(error.not_found_path(segments))
        _ -> Error(error.not_found_path(segments))
      }
  }
}