NestedText serialization format parser, emitter, and typed deserialization adapter implemented in MoonBit.
Notice: This project is a MoonBit port of the Rust nested-text crate. It inherits the Apache-2.0 OR MIT dual license.
NestedText is a human-readable data format focused on simplicity and ease of use. See nestedtext.org for the specification.
Spec version: this parser targets NestedText v3.8 (released 2025-12-26). Compliance tests are adapted from the official test suite (v3.8).
moon add OrisGo/nestedtext///|
test "quick start" {
match @nestedtext.loads("name: Alice\nage: 30", @nestedtext.Top::Any) {
Ok(Some(v)) =>
println(@nestedtext.dumps(v, @nestedtext.DumpOptions::default()))
Ok(None) => println("(empty)")
Err(e) => println("error: \{e.to_string()}")
}
}The library accepts String input. Read a .nt file first and pass its content to loads:
let content = @fs.read_file_to_string("config.nt") catch {
IOError(msg) => { println(msg); return }
}
match @nestedtext.loads(content, @nestedtext.Top::Any) {
Ok(Some(v)) => { /* use v */ }
Err(e) => println(e.to_string())
}
moonbitlang/coredoes not yet include a stable@fsmodule. See File I/O for options.
moon run cmd/main -- examples/config.nt=== examples/config.nt ===
PASS | 8 lines | 5ms | -> examples/config.nt.out
Restrict the top-level shape via environment variable:
$env:NESTEDTEXT_TOP = "list"; moon run cmd/main -- examples/data.ntThe @nestedtext library itself depends only on moonbitlang/core (stable). It does not import file I/O packages, so your own project stays in control of how files are read.
Options for reading .nt files:
| Approach | Status | Recommendation |
|---|---|---|
moonbitlang/x/fs |
Experimental (moonbitlang/x v0.4.x) |
Use today — works on native target |
moonbitlang/core/fs |
Planned (beta-preview, ~Aug 2026) | Wait for stable release |
| External language (Python, etc.) | Always available | Bridge via subprocess or FFI |
If you use moonbitlang/x/fs, add it to your application moon.pkg (not to the library):
///|
import {
"OrisGo/nestedtext",
"moonbitlang/x/fs",
}Then read and parse:
let content = @fs.read_file_to_string("data.nt") catch {
IOError(msg) => { println(msg); return }
}
match @nestedtext.loads(content, @nestedtext.Top::Any) {
Ok(Some(v)) => println(@nestedtext.dumps(v, @nestedtext.DumpOptions::default()))
Err(e) => println(e.to_string())
}The bundled CLI (cmd/main) uses moonbitlang/x/fs as a reference implementation. Once @fs lands in core, the CLI will switch to it and a convenience read_file helper may be added to the library.
Use loads to parse a NestedText document into a Value tree. Pass a Top constraint to validate the top-level shape.
///|
test "parse dictionary" {
let input = "name: Alice\nage: 30"
match @nestedtext.loads(input, @nestedtext.Top::Any) {
Ok(Some(@nestedtext.Value::Dict(pairs))) => {
@debug.assert_eq(pairs[0], ("name", @nestedtext.Value::String("Alice")))
@debug.assert_eq(pairs[1], ("age", @nestedtext.Value::String("30")))
}
_ => fail("unexpected result")
}
}
///|
test "parse nested list" {
let input = "fruits:\n - apple\n - banana"
match @nestedtext.loads(input, @nestedtext.Top::Dict) {
Ok(Some(@nestedtext.Value::Dict(pairs))) => {
@debug.assert_eq(pairs[0].0, "fruits")
let expected = @nestedtext.Value::List([
@nestedtext.Value::String("apple"),
@nestedtext.Value::String("banana"),
])
@debug.assert_eq(pairs[0].1, expected)
}
_ => fail("unexpected result")
}
}Value has three variants:
| Variant | Represents |
|---|---|
String(String) |
A scalar string value |
List(Array[Value]) |
An ordered list |
Dict(Array[(String, Value)]) |
Name-value pairs in insertion order |
Use dumps to serialize a Value back to NestedText format.
///|
test "serialize to nestedtext" {
let value = @nestedtext.Value::Dict([
("name", @nestedtext.Value::String("Alice")),
("age", @nestedtext.Value::String("30")),
])
let output = @nestedtext.dumps(value, @nestedtext.DumpOptions::default())
@debug.assert_eq(output, "name: Alice\nage: 30\n")
}
///|
test "serialize with sorted keys" {
let value = @nestedtext.Value::Dict([
("z", @nestedtext.Value::String("last")),
("a", @nestedtext.Value::String("first")),
])
let opts = @nestedtext.DumpOptions::{ indent: 4, sort_keys: true }
@debug.assert_eq(@nestedtext.dumps(value, opts), "a: first\nz: last\n")
}
///|
test "roundtrip" {
let input = "name: Alice\nage: 30"
match @nestedtext.loads(input, @nestedtext.Top::Any) {
Ok(Some(value)) => {
let output = @nestedtext.dumps(value, @nestedtext.DumpOptions::default())
match @nestedtext.loads(output, @nestedtext.Top::Any) {
Ok(Some(rv)) => @debug.assert_eq(value, rv)
_ => fail("roundtrip parse failed")
}
}
_ => fail("initial parse failed")
}
}The Deserializer provides typed extraction on top of Value — similar in spirit to Rust's serde. Use deserialize_str to parse and deserialize in one step, or deserialize_value on an already-parsed Value.
Unlike serde, this library does not use traits or derive macros. Instead, deserialization is driven by higher-order functions: you supply a closure fn(Deserializer) -> Result[T, DeserializeError] that calls typed extraction methods to build your target type.
| Aspect | Rust serde | OrisGo/nestedtext |
|---|---|---|
| Mechanism | Deserialize trait + #[derive(Deserialize)] |
Callback closure fn(Deserializer) -> Result[T, _] |
| Struct deserialization | Automatic via derive | Manual via get_field + expect_* |
| Visitor pattern | Visitor trait with visit_* methods |
Direct method calls on Deserializer |
| Error propagation | serde::de::Error trait |
Result[T, DeserializeError] chaining with try |
| Input format | Generic data model | NestedText AST only (Value enum) |
| All values are strings | N/A (format-dependent) | Yes — expect_int() etc. parse from Value::String |
///|
test "deserialize typed struct" {
fn person(
d : @nestedtext.Deserializer,
) -> Result[(String, Int), @nestedtext.DeserializeError] {
match d.get_field("name") {
Ok(nd) =>
match nd.expect_string() {
Ok(name) =>
match d.get_field("age") {
Ok(ad) =>
match ad.expect_int() {
Ok(age) => Ok((name, age))
Err(e) => Err(e)
}
Err(e) => Err(e)
}
Err(e) => Err(e)
}
Err(e) => Err(e)
}
}
let input = "name: Alice\nage: 30"
match @nestedtext.deserialize_str(input, @nestedtext.Top::Any, person) {
Ok((name, age)) => {
@debug.assert_eq(name, "Alice")
@debug.assert_eq(age, 30)
}
Err(e) => fail(e.to_string())
}
}
///|
test "deserialize list of ints" {
let d = @nestedtext.Deserializer::new(
@nestedtext.Value::List([
@nestedtext.Value::String("1"),
@nestedtext.Value::String("2"),
@nestedtext.Value::String("3"),
]),
)
match @nestedtext.deserialize_list(d, fn(d2) { d2.expect_int() }) {
Ok(ints) => @debug.assert_eq(ints, [1, 2, 3])
Err(_) => fail("unexpected error")
}
}
///|
test "deserialize optional field" {
let d = @nestedtext.Deserializer::new(@nestedtext.Value::String(""))
match d.expect_optional(fn(d2) { d2.expect_int() }) {
Ok(None) => ()
_ => fail("expected None")
}
}Deserializer extraction methods:
| Method | Target Type | Notes |
|---|---|---|
expect_string() |
String |
Identity extraction |
expect_int() |
Int |
Parses decimal representation |
expect_int64() |
Int64 |
Parses decimal representation |
expect_double() |
Double |
Parses decimal representation |
expect_bool() |
Bool |
Accepts true/True/TRUE/yes/Yes/YES (and false/no equivalents) |
expect_list() |
Array[Value] |
Raw list items |
expect_dict() |
Array[(String, Value)] |
Raw dictionary pairs |
get_field(key) |
Deserializer |
Look up a single field |
expect_optional(f) |
T? |
"" → None, otherwise → Some(f(d)) |
has_field(key) |
Bool |
Check key existence |
field_names() |
Array[String] |
All keys in the dictionary |
Parse errors carry location metadata (line number, column, source line).
///|
test "error location" {
match @nestedtext.loads("key: value", @nestedtext.Top::Any) {
Ok(value) =>
match
@nestedtext.deserialize_value(value.unwrap(), fn(d) { d.expect_int() }) {
Err(e) => @debug.assert_eq(e.message, "expected string, got dictionary")
Ok(_) => fail("expected error")
}
Err(e) => fail(e.to_string())
}
}
///|
test "parse error with location" {
match @nestedtext.loads(" key: value", @nestedtext.Top::Any) {
Err(err) => {
@debug.assert_eq(err.message, "top-level content must start in column 1.")
assert_true(err.lineno == Some(1))
}
Ok(_) => fail("expected error")
}
}NestedText documents must be valid UTF-8. The loads function takes a String, which in MoonBit is always UTF-8 encoded. Binary data containing invalid UTF-8 byte sequences will have those bytes replaced with the replacement character (U+FFFD), and loads returns an error indicating the location of the first replacement character.
Known issue: moon fmt may time out on compliance_test.mbt. The file is ~3200 lines with deeply nested literal expressions.
This project is dually licensed under the MIT License and the Apache License, Version 2.0. See LICENSE, LICENSE-MIT, and LICENSE-APACHE for details.