nestedtext

    A MoonBit port of the NestedText serialization format.

    nestedtext
    Download zip
    Author
    Version
    0.1.1
    License
    Apache-2.0 OR MIT
    Last updated
    3 months ago
    Downloads
    41

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

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io