lino-objects-codec 0.9.1

A library to encode/decode objects to/from links notation
Documentation

lino-objects-codec (Rust)

Rust CI crates.io docs.rs License: Unlicense

Rust implementation of the Links Notation Objects Codec - a universal serialization library to encode/decode objects to/from Links Notation format.

Installation

Add to your Cargo.toml:

[dependencies]
lino-objects-codec = "0.8"

Features

  • Universal Serialization: Encode objects to Links Notation format
  • Type Support: Handle all common types:
    • null (Null)
    • bool (Bool)
    • int (Int - 64-bit signed)
    • float (Float - 64-bit, including NaN, Infinity, -Infinity)
    • str (String)
    • array (Array)
    • object (Object)
  • Special Float Values: Full support for NaN, Infinity, -Infinity (which are not valid JSON)
  • Circular References: Preserved by the compact format (encode_compact) via object ids; the readable format is a plain tree and returns a CircularReference error instead
  • Object Identity: Shared references are preserved by the compact format
  • Opt-in Tracing: Set LINO_CODEC_DEBUG=1 to trace encoding and decoding, the same way in every language
  • Readable by Default: encode() writes indented, plain-text Links Notation; keys and values stay legible and diffable
  • One Record per Line: encode_line() writes the same document on one line and decode_line() reads it back exactly, so an append-only log stays greppable, tailable and countable by wc -l
  • UTF-8 Support: Full Unicode string support written as text — a newline stays a newline and a tab stays a tab; only the characters a form cannot carry are percent-escaped, and each such value is marked individually
  • Simple API: Easy-to-use encode() and decode() functions

Quick Start

use lino_objects_codec::{encode, decode, LinoValue};

// Create an object
let data = LinoValue::object([
    ("name", LinoValue::String("Alice".to_string())),
    ("age", LinoValue::Int(30)),
    ("active", LinoValue::Bool(true)),
]);

// Encode to Links Notation
let encoded = encode(&data);
assert_eq!(encoded, "(\n  name \"Alice\"\n  age 30\n  active true\n)");

// Decode back
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded, data);

The encoded document reads as:

(
  name "Alice"
  age 30
  active true
)

Output Formats

Function Output
encode(value) Readable, indented Links Notation (the default)
encode_with_indent(value, "\t") Same, with a custom indentation string
encode_line(value) The same readable document on one line, for append-only logs
encode_compact(value) The previous single-line base64 form
encode_obfuscated(value) Alias of encode_compact

decode() accepts every one of them, so files written by older versions keep working and are rewritten in the readable form the next time they are saved.

API Reference

Public value formatting

format::format_value_single_line(&str) returns one quoted or escaped readable value with no CR/LF. format::format_value_verbatim(&str) keeps real newlines and tabs, escaping only controls that the readable format cannot carry. Both can be passed directly to decode() and recover the original string. Use these helpers when composing records without encoding a temporary object first. The lower level format::quote and format::unescape are public too; quote only selects delimiters, so use the full formatter for arbitrary control characters.

use lino_objects_codec::{decode, format, LinoValue};

let text = "quoted \"value\"\nnext line";
let value = format::format_value_single_line(text);
assert!(!value.contains('\n'));
assert_eq!(decode(&value).unwrap(), LinoValue::from(text));

Optional JSON bridge

The JSON bridge and public value helpers are introduced in 0.8.0. Enable features = ["serde_json"] on the dependency. LinoValue::from(Value) and Value::try_from(LinoValue) also accept borrowed inputs. Object insertion order and the numeric text exposed by serde_json::Number survive conversion and all three wire formats through a feature-gated JsonNumber variant. The feature enables serde_json's preserve_order and arbitrary_precision settings. The guarantee starts with the supplied Value; parsing can already normalize original JSON spelling or discard duplicate keys.

json::json_to_lino and json::lino_to_json provide text conversions. Nulls and empty containers are retained by default. Pass JsonOptions { strip_empty: true } to json_to_lino_with_options or from_json_with_options to recursively remove nulls and empty arrays/objects, while retaining empty strings, false and zero. An entirely stripped root becomes null. Non-finite floats and duplicate LiNo object keys return conversion errors. Run the complete example with cargo run --features serde_json --example json_bridge.

The JSON number markers (json-number "1.200") and (json_number 1.200) are Rust feature extensions; arbitrary-precision JSON numbers are not promised by the other language implementations.

Types

LinoValue

The main value type that can represent any serializable value:

pub enum LinoValue {
    Null,
    Bool(bool),
    Int(i64),
    Float(f64),
    String(String),
    Array(Vec<LinoValue>),
    Object(Vec<(String, LinoValue)>),
}

Helper methods:

  • LinoValue::object(iter) - Create an object from key-value pairs
  • LinoValue::array(iter) - Create an array from values
  • is_null(), as_bool(), as_int(), as_float(), as_str(), as_array(), as_object() - Type checking and extraction
  • get(key) - Get a value from an object by key
  • get_index(index) - Get a value from an array by index

CodecError

Error type for codec operations:

pub enum CodecError {
    ParseError(String),
    DecodeError(String),
    UnknownType(String),
}

Functions

encode(value: &LinoValue) -> String

Encode a value to the readable, indented Links Notation format.

let value = LinoValue::Int(42);
let encoded = encode(&value);
assert_eq!(encoded, "42");

encode_with_indent(value: &LinoValue, indent: &str) -> String

Same as encode(), but with a custom indentation string (the default is two spaces).

encode_line(value: &LinoValue) -> String

Encode a value to the readable form on one line, so one record is one line of an append-only log. An object names itself with the o marker, which is what keeps (key value) unambiguous; a bare link on one line is always an array.

let value = LinoValue::object([("age", LinoValue::Int(30))]);
assert_eq!(encode_line(&value), "(o: (age 30))");

decode_line(notation: &str) -> Result<LinoValue, CodecError>

The exact inverse of encode_line(). Input spanning more than one line is rejected, so two log records can never be merged into one value.

let decoded = decode_line("(o: (age 30))").unwrap();
assert_eq!(decoded, LinoValue::object([("age", LinoValue::Int(30))]));

encode_compact(value: &LinoValue) -> String

Encode a value to the single-line, base64 form used before version 0.3.

let value = LinoValue::String("hello".to_string());
assert_eq!(encode_compact(&value), "(str aGVsbG8=)");

encode_obfuscated(value: &LinoValue) -> String

Alias of encode_compact(), named after what the base64 form actually does to the text.

decode(notation: &str) -> Result<LinoValue, CodecError>

Decode Links Notation format to a value. The readable form, the single-line form and the compact form are all accepted.

let decoded = decode("42").unwrap();
assert_eq!(decoded, LinoValue::Int(42));

let legacy = decode("(int 42)").unwrap();
assert_eq!(legacy, LinoValue::Int(42));

ObjectCodec

For advanced use cases, you can create your own codec instance:

use lino_objects_codec::ObjectCodec;

let mut codec = ObjectCodec::new();
let encoded = codec.encode(&value);
let decoded = codec.decode(&encoded)?;

Usage Examples

Basic Types

use lino_objects_codec::{encode, decode, LinoValue};

// Null
let null = LinoValue::Null;
assert_eq!(encode(&null), "null");

// Boolean
let bool_val = LinoValue::Bool(true);
assert_eq!(encode(&bool_val), "true");

// Integer
let int_val = LinoValue::Int(42);
assert_eq!(encode(&int_val), "42");

// Float
let float_val = LinoValue::Float(3.14);
assert_eq!(encode(&float_val), "3.14");

// Special floats
let inf = LinoValue::Float(f64::INFINITY);
assert_eq!(encode(&inf), "Infinity");

let nan = LinoValue::Float(f64::NAN);
assert_eq!(encode(&nan), "NaN");

// Strings are quoted, not encoded
let str_val = LinoValue::String("hello".to_string());
assert_eq!(encode(&str_val), "\"hello\"");

// Numbers written as strings stay strings
let numeric = LinoValue::String("42".to_string());
assert_eq!(decode(&encode(&numeric)).unwrap(), numeric);

Collections

use lino_objects_codec::{encode, decode, LinoValue};

// Array
let array = LinoValue::array([
    LinoValue::Int(1),
    LinoValue::Int(2),
    LinoValue::Int(3),
]);
let encoded = encode(&array);
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded.as_array().unwrap().len(), 3);

// Object
let obj = LinoValue::object([
    ("name", LinoValue::String("Alice".to_string())),
    ("age", LinoValue::Int(30)),
]);
let encoded = encode(&obj);
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded.get("name").unwrap().as_str(), Some("Alice"));

Nested Structures

use lino_objects_codec::{encode, decode, LinoValue};

let data = LinoValue::object([
    ("users", LinoValue::array([
        LinoValue::object([
            ("id", LinoValue::Int(1)),
            ("name", LinoValue::String("Alice".to_string())),
        ]),
        LinoValue::object([
            ("id", LinoValue::Int(2)),
            ("name", LinoValue::String("Bob".to_string())),
        ]),
    ])),
    ("metadata", LinoValue::object([
        ("version", LinoValue::Int(1)),
        ("count", LinoValue::Int(2)),
    ])),
]);

let encoded = encode(&data);
let decoded = decode(&encoded).unwrap();

// Access nested values
let users = decoded.get("users").unwrap().as_array().unwrap();
assert_eq!(users.len(), 2);

From Traits

LinoValue implements From for common Rust types:

use lino_objects_codec::LinoValue;

let null: LinoValue = ().into();
let bool_val: LinoValue = true.into();
let int_val: LinoValue = 42i64.into();
let float_val: LinoValue = 3.14f64.into();
let str_val: LinoValue = "hello".into();
let vec_val: LinoValue = vec![1i64, 2, 3].into();
let opt_val: LinoValue = Some(42i64).into();
let none_val: LinoValue = None::<i64>.into();

How It Works

The codec encodes values using the Links Notation format.

Readable format (the default)

One ( ) construct carries both objects and arrays, at every level including the root. Lines of the form key value make an object, bare-value lines make an array:

(
  type "RouterState"
  server (
    host "127.0.0.1"
    port 18878
  )
  models (
    "claude-haiku"
    "claude-opus"
  )
)
  • Strings are double-quoted and written as text: name "Alice"
  • Numbers, true, false and null are bare, so types survive a round trip
  • NaN, Infinity and -Infinity are written as such
  • An empty array is (); an empty object is ( + newline + )
  • A string is written as text whatever it holds: a newline stays a newline and a tab stays a tab, so every word stays greppable
  • A string containing the quote delimiter is written between a run of at least three of them — """say "hi"""" — rather than by doubling the quote
  • Only the characters this form cannot carry — a carriage return and the remaining control characters — are percent-escaped, in a value marked on its own as (escaped "first%0D"); everything around it stays readable, and (base64 "…") written by earlier versions is still decoded
  • A value that occurs more than once is written out every time: a shared reference would make one record depend on another

Reading the format back requires links-notation 0.14 semantics, where a parenthesis opens a nested indentation context.

Compact format (encode_compact)

The previous single-line form, kept for compatibility and for cases where size matters more than legibility:

  • Basic types: (int 42), (str aGVsbG8=), (bool true)
  • Strings are base64-encoded here, and only here: this is the one form that asks for it by name, and encode() never reaches for it
  • Arrays: (array (int 1) (int 2) (int 3))
  • Objects: (object ((str a2V5) (int 42)) ...)
  • Special floats: (float NaN), (float Infinity), (float -Infinity)

For structures with shared references or circular references, the compact form uses object IDs:

  • Format: (obj_0: array ...) or (obj_0: object ...)
  • References: obj_0

decode() detects which of the two forms it is given, so previously written files keep decoding.

Debugging

Tracing is off by default. Turn it on to see what the codec does, either from the environment or from code:

LINO_CODEC_DEBUG=1 cargo run --example basic_usage   # 1, true, yes or on
use lino_objects_codec::debug;

debug::set_debug_enabled(Some(true)); // force on
debug::set_debug_enabled(None);       // follow LINO_CODEC_DEBUG again

Trace lines are written to standard error, prefixed with [lino-codec]. The same switch and the same LINO_CODEC_DEBUG variable exist in the JavaScript, Python and C# implementations.

Development

# Run tests
cargo test

# Run example
cargo run --example basic_usage

# Build documentation
cargo doc --open

License

This project is licensed under the Unlicense - see the LICENSE file for details.