// Copyright 2026 moonbit-toml contributors
//
// SPDX-License-Identifier: MIT
///|
priv struct Parser {
sc : Scanner
root : Table
mut cur : Table
}
///|
/// Parses a full TOML document and returns the root table.
///
/// Raises `ParseError` when the input is not a valid TOML v1.0.0 document;
/// the error carries the 1-based line/column of the problem.
///
/// # Example
///
/// ```mbt check
/// test {
/// let doc = @toml.parse(
/// "title = \"demo\"\n[server]\nhost = \"127.0.0.1\"\nport = 8080",
/// )
/// @test.assert_eq(doc.get_string("title"), Some("demo"))
/// @test.assert_eq(doc.get_path("server.port"), Some(@toml.Value::Int(8080L)))
/// }
/// ```
pub fn parse(input : String) -> Table raise ParseError {
let sc = Scanner::make(input)
let root = Table::make(Explicit)
let parser = { sc, root, cur: root, }
parser.parse_document()
root
}
///|
/// Parses a single TOML value fragment such as `"1_000"`, `"[1, 2]"` or
/// `"1979-05-27T07:32:00Z"`. Useful for decoding individual settings.
///
/// # Example
///
/// ```mbt check
/// test {
/// @test.assert_eq(@toml.parse_value("1_000").as_int(), Some(1000L))
/// @test.assert_eq(@toml.parse_value("1979-05-27").type_name(), "date-local")
/// }
/// ```
pub fn parse_value(input : String) -> Value raise ParseError {
let sc = Scanner::make(input)
sc.skip_ws_newlines_comments()
let v = sc.parse_value()
sc.skip_ws_newlines_comments()
if !sc.at_eof() {
raise sc.error_here(
InvalidSyntax(sc.here(), "trailing characters after value"),
)
}
v
}
///|
fn Parser::parse_document(self : Parser) -> Unit raise ParseError {
while true {
self.sc.skip_inline_ws()
if self.sc.at_eof() {
return
}
let c = self.sc.peek()
match c {
Some('#') => {
self.sc.skip_comment()
self.sc.expect_line_end()
}
Some('\n') | Some('\r') => ignore(self.sc.newline())
Some('[') => self.parse_table_header()
Some(_) => {
self.parse_keyval(self.cur)
self.sc.expect_line_end()
}
None => return
}
}
}
///|
/// Parses a `[table]` or `[[array of tables]]` header line.
fn Parser::parse_table_header(self : Parser) -> Unit raise ParseError {
let pos = self.sc.here()
ignore(self.sc.bump()) // '['
let is_aot = self.sc.bump_if('[')
let path = self.sc.parse_key_path()
self.sc.skip_inline_ws()
if !self.sc.bump_if(']') {
raise self.sc.error_here(
InvalidSyntax(self.sc.here(), "expected ']' to close the table header"),
)
}
if is_aot {
if !self.sc.bump_if(']') {
raise self.sc.error_here(
InvalidSyntax(
self.sc.here(),
"expected ']]' to close the array-of-tables header",
),
)
}
}
self.sc.expect_line_end()
if is_aot {
self.define_array_of_tables(path, pos)
} else {
self.define_table(path, pos)
}
}
///|
/// Parses a dotted key path: `simple.key` or `"quoted".key`.
fn Scanner::parse_key_path(self : Scanner) -> Array[String] raise ParseError {
let parts : Array[String] = []
while true {
self.skip_inline_ws()
parts.push(self.parse_simple_key())
self.skip_inline_ws()
if !self.bump_if('.') {
break
}
}
parts
}
///|
/// Parses one key component: a bare key or a quoted (single-line) string.
fn Scanner::parse_simple_key(self : Scanner) -> String raise ParseError {
let c = match self.peek() {
Some(c) => c
None => raise self.error_here(UnexpectedEof(self.here()))
}
if c == '"' {
self.parse_basic_string(false)
} else if c == '\'' {
self.parse_literal_string(false)
} else if is_bare_key_char(c) {
let start = self.pos
while self.peek() is Some(c) && is_bare_key_char(c) {
ignore(self.bump())
}
self.text_since(start)
} else {
raise self.error_here(UnexpectedChar(self.here(), c))
}
}
///|
/// Parses `key = value` into the given table, applying the dotted-key
/// definition rules.
fn Parser::parse_keyval(self : Parser, owner : Table) -> Unit raise ParseError {
let pos = self.sc.here()
let path = self.sc.parse_key_path()
self.sc.skip_inline_ws()
if !self.sc.bump_if('=') {
raise self.sc.error_here(
InvalidSyntax(self.sc.here(), "expected '=' after key"),
)
}
self.sc.skip_inline_ws()
let value = self.sc.parse_value()
insert_dotted(owner, path, value, pos)
}
///|
/// Dispatches value parsing by the first character.
fn Scanner::parse_value(self : Scanner) -> Value raise ParseError {
let c = match self.peek() {
Some(c) => c
None => raise self.error_here(UnexpectedEof(self.here()))
}
if c == '"' {
Value::Str(self.parse_string_value())
} else if c == '\'' {
Value::Str(self.parse_string_value())
} else if c == '[' {
self.parse_array()
} else if c == '{' {
self.parse_inline_table()
} else if c == 't' {
if self.bump_str("true") {
Value::Bool(true)
} else {
raise self.error_here(InvalidValue(self.here(), 't'))
}
} else if c == 'f' {
if self.bump_str("false") {
Value::Bool(false)
} else {
raise self.error_here(InvalidValue(self.here(), 'f'))
}
} else if is_digit(c) || c == '+' || c == '-' || c == 'i' || c == 'n' {
self.parse_number_or_datetime()
} else {
raise self.error_here(InvalidValue(self.here(), c))
}
}
///|
fn Scanner::parse_array(self : Scanner) -> Value raise ParseError {
self.enter_nesting()
let v = self.parse_array_body()
self.leave_nesting()
v
}
///|
fn Scanner::parse_array_body(self : Scanner) -> Value raise ParseError {
ignore(self.bump()) // '['
let items : Array[Value] = []
while true {
self.skip_ws_newlines_comments()
if self.bump_if(']') {
return Value::Array(items)
}
items.push(self.parse_value())
self.skip_ws_newlines_comments()
if self.bump_if(',') {
continue
}
if self.bump_if(']') {
return Value::Array(items)
}
// Produce a helpful message for a missing comma.
match self.peek() {
Some(c) =>
if c != ']' && c != '#' {
raise self.error_here(
InvalidSyntax(self.here(), "expected ',' or ']' in array"),
)
}
None => raise self.error_here(UnexpectedEof(self.here()))
}
raise self.error_here(UnexpectedEof(self.here()))
}
abort("unreachable")
}
///|
fn Scanner::parse_inline_table(self : Scanner) -> Value raise ParseError {
self.enter_nesting()
let v = self.parse_inline_table_body()
self.leave_nesting()
v
}
///|
fn Scanner::parse_inline_table_body(self : Scanner) -> Value raise ParseError {
ignore(self.bump()) // '{'
let table = Table::make(Inline)
self.skip_inline_ws()
if self.bump_if('}') {
return Value::Table(table)
}
while true {
self.skip_inline_ws()
let pos = self.here()
let path = self.parse_key_path()
self.skip_inline_ws()
if !self.bump_if('=') {
raise self.error_here(
InvalidSyntax(self.here(), "expected '=' after key"),
)
}
self.skip_inline_ws()
let value = self.parse_value()
insert_dotted(table, path, value, pos)
self.skip_inline_ws()
if self.bump_if(',') {
self.skip_inline_ws()
// A newline is not allowed between inline table entries.
if self.peek() == Some('\n') || self.peek() == Some('\r') {
raise self.error_here(
InvalidSyntax(
self.here(),
"inline tables must be written on a single line",
),
)
}
continue
}
if self.bump_if('}') {
return Value::Table(table)
}
match self.peek() {
Some('\n') | Some('\r') =>
raise self.error_here(
InvalidSyntax(
self.here(),
"inline tables must be written on a single line",
),
)
Some(c) => raise self.error_here(UnexpectedChar(self.here(), c))
None => raise self.error_here(UnexpectedEof(self.here()))
}
}
abort("unreachable")
}
///|
/// Applies a `[table]` header at `path`, enforcing TOML definition rules.
fn Parser::define_table(
self : Parser,
path : Array[String],
pos : Position,
) -> Unit raise ParseError {
let key_chain = join_key_path(path)
let final_idx = path.length() - 1
let mut t = self.root
for i in 0..
match sub.defined {
Implicit => {
sub.defined = Explicit
self.cur = sub
}
Explicit => raise self.sc.error_here(DuplicateKey(pos, key_chain))
Dotted => raise self.sc.error_here(TableConflict(pos, key_chain))
Inline => raise self.sc.error_here(TableConflict(pos, key_chain))
}
Some(_) => raise self.sc.error_here(DuplicateKey(pos, key_chain))
None => {
let sub = Table::make(Explicit)
t.set(k, Value::Table(sub))
self.cur = sub
}
}
}
///|
/// Applies a `[[array of tables]]` header at `path`.
fn Parser::define_array_of_tables(
self : Parser,
path : Array[String],
pos : Position,
) -> Unit raise ParseError {
let key_chain = join_key_path(path)
let final_idx = path.length() - 1
let mut t = self.root
for i in 0..
if is_array_of_tables(items) {
let elem = Table::make(Explicit)
elem.aot = true
items.push(Value::Table(elem))
self.cur = elem
} else {
raise self.sc.error_here(TableConflict(pos, key_chain))
}
Some(_) => raise self.sc.error_here(TableConflict(pos, key_chain))
None => {
let elem = Table::make(Explicit)
elem.aot = true
t.set(k, Value::Array([Value::Table(elem)]))
self.cur = elem
}
}
}
///|
/// Navigates one path segment as an intermediate step of a header walk.
fn Parser::navigate(
self : Parser,
t : Table,
k : String,
pos : Position,
key_chain : String,
) -> Table raise ParseError {
match t.get(k) {
Some(Value::Table(sub)) =>
match sub.defined {
Inline => raise self.sc.error_here(TableConflict(pos, key_chain))
_ => sub
}
Some(Value::Array(items)) =>
// Walking into an array of tables continues inside its last element
// (e.g. `[fruit.physical]` after `[[fruit]]`).
if is_array_of_tables(items) {
match items[items.length() - 1] {
Value::Table(last) => last
_ => abort("unreachable")
}
} else {
raise self.sc.error_here(TableConflict(pos, key_chain))
}
Some(_) => raise self.sc.error_here(TableConflict(pos, key_chain))
None => {
let sub = Table::make(Implicit)
t.set(k, Value::Table(sub))
sub
}
}
}
///|
fn join_key_path(path : Array[String]) -> String {
let sb = StringBuilder::StringBuilder()
for i, part in path {
if i > 0 {
sb.write_char('.')
}
sb.write_string(part)
}
sb.to_string()
}
///|
fn is_array_of_tables(items : Array[Value]) -> Bool {
let mut all = items.length() > 0
for item in items {
match item {
Value::Table(t) => if !t.aot { all = false }
_ => all = false
}
}
all
}
///|
/// Implements dotted-key insertion with the TOML collision rules:
/// intermediate tables may be reused if they are implicit, dotted or
/// header-defined, but never inline; the final key must be new.
fn insert_dotted(
owner : Table,
path : Array[String],
value : Value,
pos : Position,
) -> Unit raise ParseError {
let key_chain = join_key_path(path)
let final_idx = path.length() - 1
let mut t = owner
for i in 0..
match sub.defined {
Inline => raise ParseError(TableConflict(pos, key_chain))
_ => t = sub
}
Some(_) => raise ParseError(DuplicateKey(pos, key_chain))
None => {
let sub = Table::make(Dotted)
t.set(k, Value::Table(sub))
t = sub
}
}
}
let k = path[final_idx]
if t.get(k) is Some(_) {
raise ParseError(DuplicateKey(pos, key_chain))
}
t.set(k, value)
}