nestedtext

A MoonBit port of the NestedText serialization format.

nestedtext
moon add OrisGo/nestedtext@0.1.1
Download zip
Author
Version
0.1.1
License
Apache-2.0 OR MIT
Last updated
last month
Downloads
13

Dependencies

README

#OrisGo/nestedtext

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.

#Quick Start

#As a library

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/core does not yet include a stable @fs module. See File I/O for options.

#As a CLI tool

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.nt

#File I/O

The @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:

ApproachStatusRecommendation
moonbitlang/x/fsExperimental (moonbitlang/x v0.4.x)Use today — works on native target
moonbitlang/core/fsPlanned (beta-preview, ~Aug 2026)Wait for stable release
External language (Python, etc.)Always availableBridge via subprocess or FFI

If you use moonbitlang/x/fs, add it to your application moon.pkg (not to the library):

import {
"OrisGo/nestedtext" @nestedtext,
"moonbitlang/x/fs" @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.

#Parsing

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:

VariantRepresents
String(String)A scalar string value
List(Array[Value])An ordered list
Dict(Array[(String, Value)])Name-value pairs in insertion order

#Serializing

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")
}
}

#Typed Deserialization

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.

#Comparison with Rust's serde

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.

AspectRust serdeOrisGo/nestedtext
MechanismDeserialize trait + #[derive(Deserialize)]Callback closure fn(Deserializer) -> Result[T, _]
Struct deserializationAutomatic via deriveManual via get_field + expect_*
Visitor patternVisitor trait with visit_* methodsDirect method calls on Deserializer
Error propagationserde::de::Error traitResult[T, DeserializeError] chaining with try
Input formatGeneric data modelNestedText AST only (Value enum)
All values are stringsN/A (format-dependent)Yes — expect_int() etc. parse from Value::String

#Usage

///|
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:

MethodTarget TypeNotes
expect_string()StringIdentity extraction
expect_int()IntParses decimal representation
expect_int64()Int64Parses decimal representation
expect_double()DoubleParses decimal representation
expect_bool()BoolAccepts 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)DeserializerLook up a single field
expect_optional(f)T?""None, otherwise → Some(f(d))
has_field(key)BoolCheck key existence
field_names()Array[String]All keys in the dictionary

#Error Handling

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")
}
}

#Important: Encoding

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.

#License

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.

#
DeserializeError

pub(all) struct DeserializeError {
message : String
} derive(Eq,
Debug
)

Error returned when deserialization of a Value into a typed target fails.

#
DeserializeError::missing_field

fn DeserializeError::missing_field(field_name : String) -> DeserializeError

Create an error for a missing dictionary field.

#
DeserializeError::new

fn DeserializeError::new(message : String) -> DeserializeError

Create a DeserializeError with a free-form message.

#
DeserializeError::parse_error

fn DeserializeError::parse_error(value : String, target_type : String) -> DeserializeError

Create an error for a failed string-to-primitive conversion.

#
DeserializeError::type_mismatch

fn DeserializeError::type_mismatch(expected : String, actual : String) -> DeserializeError

Create an error for a type mismatch (e.g. expected a list but got a string).

#
Deserializer

pub(all) struct Deserializer {
value : Value
} derive(Eq,
Debug
)

A typed view over a parsed Value that extracts MoonBit values.

#
Deserializer::as_value

fn Deserializer::as_value(self : Deserializer) -> Value

Return the raw Value this deserializer wraps.

#
Deserializer::expect_bool

fn Deserializer::expect_bool(self : Deserializer) -> Result[Bool, DeserializeError]

Extract the value as a Bool.

Accepts true/True/TRUE/yes/Yes/YES for true, and false/False/FALSE/no/No/NO for false. Errors if the value is not a string or the text is not a recognised boolean representation.

#
Deserializer::expect_dict

fn Deserializer::expect_dict(self : Deserializer) -> Result[Array[(String, Value)], DeserializeError]

Extract the underlying dictionary pairs. Errors if the value is not a Dict.

#
Deserializer::expect_double

fn Deserializer::expect_double(self : Deserializer) -> Result[Double, DeserializeError]

Extract the value as a Double by parsing the string representation.

Errors

Returns DeserializeError::type_mismatch when the value is not a string, or DeserializeError::parse_error when the string cannot be parsed as a Double.

#
Deserializer::expect_int

fn Deserializer::expect_int(self : Deserializer) -> Result[Int, DeserializeError]

Extract the value as an Int by parsing the string representation.

Errors

Returns DeserializeError::type_mismatch when the value is not a string, or DeserializeError::parse_error when the string cannot be parsed as an Int.

#
Deserializer::expect_int64

fn Deserializer::expect_int64(self : Deserializer) -> Result[Int64, DeserializeError]

Extract the value as an Int64 by parsing the string representation.

Errors

Returns DeserializeError::type_mismatch when the value is not a string, or DeserializeError::parse_error when the string cannot be parsed as an Int64.

#
Deserializer::expect_list

fn Deserializer::expect_list(self : Deserializer) -> Result[Array[Value], DeserializeError]

Extract the underlying list items. Errors if the value is not a List.

#
Deserializer::expect_optional

fn[T] Deserializer::expect_optional(self : Deserializer, f : (Deserializer) -> Result[T, DeserializeError]) -> Result[T?, DeserializeError]

Deserialize an optional field.

An empty string ("") is mapped to None; any other value is passed through f and wrapped in Some.

#
Deserializer::expect_string

fn Deserializer::expect_string(self : Deserializer) -> Result[String, DeserializeError]

Extract the value as a String. Errors if the underlying value is not a string.

#
Deserializer::field_names

fn Deserializer::field_names(self : Deserializer) -> Array[String]

Return the list of field names if the value is a dictionary. Returns an empty array otherwise.

#
Deserializer::get_field

fn Deserializer::get_field(self : Deserializer, key : String) -> Result[Deserializer, DeserializeError]

Retrieve a field from the dictionary by key name. Errors if the value is not a Dict or the key is missing.

#
Deserializer::has_field

fn Deserializer::has_field(self : Deserializer, key : String) -> Bool

Check whether a key exists in the dictionary. Returns false if the underlying value is not a dictionary.

#
Deserializer::new

fn Deserializer::new(value : Value) -> Deserializer

Wrap a Value for typed extraction.

#
DumpOptions

pub(all) struct DumpOptions {
indent : Int
sort_keys : Bool
} derive(
Debug
)

Options for controlling NestedText output formatting.

Fields

  • indent — Number of spaces per indentation level (default: 4).
  • sort_keys — When true, dictionary keys are emitted in ascending string order (default: false).

#
DumpOptions::default

fn DumpOptions::default() -> DumpOptions

Default formatting options: 4-space indent, no key sorting.

#
ErrorKind

pub(all) enum ErrorKind {
InvalidIndentation
TabInIndentation
UnrecognizedLine
UnexpectedLineType
DuplicateKey
InvalidIndentLevel
UnterminatedInlineList
UnterminatedInlineDict
InvalidInlineCharacter
TrailingContent
DeserializationError
} derive(Eq,
Debug
)

Categories of errors that can occur during NestedText parsing.

Variants

  • InvalidIndentation — Indentation uses an unknown mixture of spaces or does not match any previously established indentation level.
  • TabInIndentation — A tab character was used for indentation. NestedText requires spaces.
  • UnrecognizedLine — The line contains characters that cannot be recognised (e.g. invalid UTF-8).
  • UnexpectedLineType — The line type (dict item, list item, string) does not match the expected shape of the enclosing value.
  • DuplicateKey — A dictionary key appears more than once within the same dictionary.
  • InvalidIndentLevel — The indentation level does not correspond to any level established by the containing structure (partial dedent).
  • UnterminatedInlineList — An inline list [...] is missing its closing bracket.
  • UnterminatedInlineDict — An inline dictionary {...} is missing its closing brace.
  • InvalidInlineCharacter — An unexpected character was found inside an inline list or dictionary.
  • TrailingContent — Extra characters were found after a complete inline value.
  • DeserializationError — A typed-deserialization step failed. The accompanying message provides the detail.

#
NestedTextError

pub(all) struct NestedTextError {
kind : ErrorKind
message : String
lineno : Int?
colno : Int?
line : String?
} derive(Eq,
Debug
)

A parse or deserialization error with optional source-location context.

Fields

  • kind — The category of error.
  • message — A human-readable description of the problem.
  • lineno — The 1-based line number where the error occurred (if known).
  • colno — The 1-based column number where the error occurred (if known).
  • line — The text of the offending line (if available).

#
NestedTextError::at

fn NestedTextError::at(kind : ErrorKind, message : String, lineno : Int, colno : Int, line : String) -> NestedTextError

Create an error with full location information.

#
NestedTextError::new

fn NestedTextError::new(kind : ErrorKind, message : String) -> NestedTextError

Create an error without location information.

#
NestedTextError::to_string

fn NestedTextError::to_string(self : NestedTextError) -> String

Format the error as a human-readable string.

If a line number is available the output is "line <n>: <message>"; otherwise it is just <message>.

#
NestedTextError::with_colno

fn NestedTextError::with_colno(self : NestedTextError, colno : Int) -> NestedTextError

Set or replace the column number on an existing error.

#
NestedTextError::with_line

fn NestedTextError::with_line(self : NestedTextError, line : String) -> NestedTextError

Set or replace the source line on an existing error.

#
NestedTextError::with_lineno

fn NestedTextError::with_lineno(self : NestedTextError, lineno : Int) -> NestedTextError

Set or replace the line number on an existing error.

#
Top

pub(all) enum Top {
Dict
List
String
Any
} derive(Eq,
Debug
)

Constraint for the top-level value accepted by loads.

Variants

  • Dict — The document must be a dictionary (key-value pairs). An empty document yields an empty dictionary.
  • List — The document must be a list. An empty document yields an empty list.
  • String — The document must be a single string scalar. An empty document yields the empty string "".
  • Any — The document may be any type. An empty document yields None.

#
Value

pub(all) enum Value {
String(String)
List(Array[Value])
Dict(Array[(String, Value)])
} derive(Eq,
Debug
)

The three NestedText value types.

NestedText has string scalars, lists, and dictionaries. Dictionaries keep insertion order by storing key-value pairs in an array.

#
Value::as_dict

fn Value::as_dict(self : Value) -> Array[(String, Value)]?

If the value is a Dict, returns Some(pairs); otherwise returns None.

#
Value::as_list

fn Value::as_list(self : Value) -> Array[Value]?

If the value is a List, returns Some(items); otherwise returns None.

#
Value::as_string

fn Value::as_string(self : Value) -> String?

If the value is a String, returns Some(s); otherwise returns None.

#
Value::get

fn Value::get(self : Value, key : String) -> Value?

Look up a key in a dictionary value.

Returns Some(v) if the value is a Dict and contains key; returns None otherwise.

#
Value::is_dict

fn Value::is_dict(self : Value) -> Bool

Returns true when the value is a Dict.

#
Value::is_list

fn Value::is_list(self : Value) -> Bool

Returns true when the value is a List.

#
Value::is_string

fn Value::is_string(self : Value) -> Bool

Returns true when the value is a String.

#
deserialize_list

fn[T] deserialize_list(d : Deserializer, f : (Deserializer) -> Result[T, DeserializeError]) -> Result[Array[T], DeserializeError]

Deserialize a list where each element is decoded with f.

Parameters

  • d — A deserializer that should wrap a List value.
  • f — A function that receives a fresh Deserializer for each element.

Returns

The array of decoded elements on success. Returns DeserializeError::type_mismatch when the underlying value is not a list.

#
deserialize_str

fn[T] deserialize_str(input : String, top : Top, f : (Deserializer) -> Result[T, DeserializeError]) -> Result[T, NestedTextError]

Parse a NestedText string and deserialize the result into a typed value using f.

Parse errors (format / syntax) are returned as NestedTextError; deserialization errors are wrapped in a NestedTextError with ErrorKind::DeserializationError.

#
deserialize_value

fn[T] deserialize_value(value : Value, f : (Deserializer) -> Result[T, DeserializeError]) -> Result[T, DeserializeError]

Convert a Value into a typed value using f.

Parameters

  • value — The parsed value to deserialize.
  • f — A function that extracts the typed result from a Deserializer.

#
dumps

fn dumps(value : Value, options : DumpOptions) -> String

Serialize a Value to a NestedText string.

Parameters

  • value — The parsed NestedText value to emit.
  • options — Formatting options (indent size, key sorting).

Returns

A NestedText document string, including a trailing newline.

#
loads

fn loads(input : String, top : Top) -> Result[Value?, NestedTextError]

Parse a NestedText document string.

Parameters

  • input — The NestedText source text.
  • top — Constrains the expected top-level shape. Pass Top::Any when the structure is not known ahead of time.

Returns

  • Ok(None) — The document is empty and top is Top::Any.
  • Ok(Some(v)) — Successful parse. The returned Value matches the constraint given by top.
  • Err(e) — A NestedTextError describing the parse failure.