A C# library for working with Links Notation format. This library provides universal serialization/deserialization for C# objects with circular reference support.
- Universal Serialization: Encode C# objects to Links Notation format
- Type Support: Handle common C# types:
- Basic types:
null,bool,int,long,float,double,string - Collections:
List<object?>,Dictionary<string, object?> - Special float values:
NaN,Infinity,-Infinity
- Basic types:
- Readable by Default:
Codec.Encode()writes plain, indented text that can be read and reviewed - One Record per Line:
Codec.EncodeLine()writes the same document on one line andCodec.DecodeLine()reads it back exactly, so an append-only log stays greppable, tailable and countable bywc -l - Object Identity: Shared references and circular references are preserved by the compact format (
Codec.EncodeCompact) via object ids - Full Unicode: Strings are always written as text — a newline stays a newline and a tab stays a tab, so every word stays greppable; only the characters a form cannot carry are percent-escaped, in a value marked individually as
(escaped "…") - Opt-in Tracing: Set
LINO_CODEC_DEBUG=1to trace encoding and decoding, the same way in every language - Simple API: Easy-to-use
Codec.Encode()andCodec.Decode()functions - Thread Safe: Each operation uses a fresh codec instance
Install-Package Lino.Objects.Codec
dotnet add package Lino.Objects.Codec<PackageReference Include="Lino.Objects.Codec" Version="0.1.0" />using Lino.Objects.Codec;
// Encode basic types
var encoded = Codec.Encode(new Dictionary<string, object?>
{
{ "name", "Alice" },
{ "age", 30 },
{ "active", true }
});
Console.WriteLine(encoded);
// Output:
// (
// name "Alice"
// age 30
// active true
// )
// Decode back to C# object
var decoded = Codec.Decode(encoded) as Dictionary<string, object?>;
Console.WriteLine($"Name: {decoded?["name"]}, Age: {decoded?["age"]}");
// Output: Name: Alice, Age: 30using Lino.Objects.Codec;
// null
Console.WriteLine(Codec.Decode(Codec.Encode(null))); // null
// Booleans
Console.WriteLine(Codec.Decode(Codec.Encode(true))); // True
Console.WriteLine(Codec.Decode(Codec.Encode(false))); // False
// Numbers (integers and floats)
Console.WriteLine(Codec.Decode(Codec.Encode(42))); // 42
Console.WriteLine(Codec.Decode(Codec.Encode(-123))); // -123
Console.WriteLine(Codec.Decode(Codec.Encode(3.14))); // 3.14
// Special number values
Console.WriteLine(Codec.Decode(Codec.Encode(double.PositiveInfinity))); // ∞
Console.WriteLine(Codec.Decode(Codec.Encode(double.NegativeInfinity))); // -∞
Console.WriteLine(double.IsNaN((double)Codec.Decode(Codec.Encode(double.NaN))!)); // True
// Strings (with full Unicode support)
Console.WriteLine(Codec.Decode(Codec.Encode("hello"))); // hello
Console.WriteLine(Codec.Decode(Codec.Encode("你好世界 🌍"))); // 你好世界 🌍
Console.WriteLine(Codec.Decode(Codec.Encode("multi\nline\nstring"))); // multi\nline\nstringusing Lino.Objects.Codec;
// Lists
var list = new List<object?> { 1, 2, 3, "hello", true, null };
var encoded = Codec.Encode(list);
var decoded = Codec.Decode(encoded) as List<object?>;
// decoded contains [1, 2, 3, "hello", true, null]
// Nested lists
var nested = new List<object?>
{
new List<object?> { 1, 2 },
new List<object?> { 3, 4 },
new List<object?> { 5, new List<object?> { 6, 7 } }
};
decoded = Codec.Decode(Codec.Encode(nested)) as List<object?>;
// Dictionaries
var person = new Dictionary<string, object?>
{
{ "name", "Bob" },
{ "age", 25 },
{ "email", "bob@example.com" }
};
decoded = Codec.Decode(Codec.Encode(person));
// Complex nested structures
var complexData = new Dictionary<string, object?>
{
{
"users", new List<object?>
{
new Dictionary<string, object?> { { "id", 1 }, { "name", "Alice" } },
new Dictionary<string, object?> { { "id", 2 }, { "name", "Bob" } }
}
},
{
"metadata", new Dictionary<string, object?>
{
{ "version", 1 },
{ "count", 2 }
}
}
};
decoded = Codec.Decode(Codec.Encode(complexData));| Method | Output |
|---|---|
Codec.Encode(obj) |
Readable, indented Links Notation (the default) |
Codec.Encode(obj, "\t") |
Same, with a custom indentation string |
Codec.EncodeLine(obj) |
The same readable document on one line, for append-only logs |
Codec.EncodeCompact(obj) |
The previous single-line, base64 form |
Codec.EncodeObfuscated(obj) |
Alias of Codec.EncodeCompact |
Codec.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.
Object identity -- shared nodes and cycles -- is a property of the compact
format, which names shared nodes with obj_N ids. The readable format is a plain
tree with nowhere to put those ids, so Codec.Encode throws
CircularReferenceException on a cycle. Use Codec.EncodeCompact when you need
identity preserved:
using Lino.Objects.Codec;
// Self-referencing list -- preserved by the compact format
var selfRef = new List<object?>();
selfRef.Add(selfRef); // Circular reference
var encoded = Codec.EncodeCompact(selfRef);
// Output: (obj_0: list obj_0)
var decoded = Codec.Decode(encoded) as List<object?>;
Console.WriteLine(ReferenceEquals(decoded, decoded?[0])); // True - Reference preserved
// Shared references -- the same object is restored once
var shared = new Dictionary<string, object?> { { "shared", "data" } };
var container = new Dictionary<string, object?>
{
{ "first", shared },
{ "second", shared }
};
var decodedContainer = Codec.Decode(Codec.EncodeCompact(container)) as Dictionary<string, object?>;
Console.WriteLine(ReferenceEquals(decodedContainer?["first"], decodedContainer?["second"])); // True
// The readable format rejects a cycle rather than losing the identity
try
{
Codec.Encode(selfRef);
}
catch (CircularReferenceException error)
{
Console.WriteLine(error.GetType().Name); // CircularReferenceException
}The library uses the links-notation format as the serialization target.
Codec.Encode writes one ( ) construct for both dictionaries and lists, at
every level including the root. Lines of the form key value make a dictionary,
bare-value lines make a list:
- Strings are double-quoted and written as text:
name "Alice" - Numbers,
true,falseandnullare bare, so types survive a round trip NaN,Infinityand-Infinityare written as such- An empty list is
(); an empty dictionary 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
The same readable document on one line, so an append-only log holds one record
per line -- appending is one write, compaction cuts at a newline, and grep,
tail -f and wc -l all treat a line as one event:
(o: (bytes 2827) (complete true) (server (o: (host "127.0.0.1") (port 18878))))
- A dictionary is
(o: (key value) …)and an empty dictionary is(o:) - A list is
(value …)and an empty list is() - Scalars and strings are written exactly as in the indented form
- The
omarker removes the ambiguity a flat layout otherwise has: a bare( )on one line is always a list, so a hand-written(a 1)is the two-element list, not the one-pair dictionary Codec.Decodereads this form too;Codec.DecodeLineis its exact inverse and rejects input spanning more than one line
The previous single-line form, kept for compatibility and for the object graphs the readable tree cannot express (shared and circular references):
- Basic types carry a type marker:
(int 42),(str SGVsbG8=),(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 - Collections with self-references use
(obj_id: type content...), e.g.(obj_0: dict ((str c2VsZg==) obj_0))for{"self": obj} - Circular references use direct object ID references:
obj_0(without arefkeyword)
Codec.Decode detects which of the two forms it is given, so previously written
files keep decoding, and every language reads the compact documents the others
write.
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 dotnet run # 1, true, yes or onusing Lino.Objects.Codec;
CodecDebug.SetEnabled(true); // force on
CodecDebug.SetEnabled(null); // follow LINO_CODEC_DEBUG againTrace 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 Rust implementations.
Encode a C# object to Links Notation format.
Parameters:
obj- The C# object to encode (can be null)
Returns:
- String representation in Links Notation format
Throws:
NotSupportedException- If the object type is not supported
Decode Links Notation format to a C# object.
Parameters:
notation- String in Links Notation format
Returns:
- Reconstructed C# object (or null)
Throws:
InvalidOperationException- If the type marker is unknown
Encode a C# object into the readable format on one line.
Parameters:
obj- The C# object to encode (can be null)
Returns:
- String representation in readable Links Notation format, holding no newline
Codec.EncodeLine(new Dictionary<string, object?> { ["age"] = 30 }); // (o: (age 30))Decode one line of a readable Links Notation log. The exact inverse of
Codec.EncodeLine.
Parameters:
notation- One line written byCodec.EncodeLine
Returns:
- Reconstructed C# object (or null)
Throws:
FormatException- If the input spans more than one line or is malformed
The main codec class that performs encoding and decoding. The static Codec class creates a new instance for each operation to ensure thread safety.
using Lino.Objects.Codec;
var codec = new ObjectCodec();
var encoded = codec.Encode(new List<object?> { 1, 2, 3 });
var decoded = codec.Decode(encoded);# Clone the repository
git clone https://github.com/link-foundation/lino-objects-codec.git
cd lino-objects-codec/csharp
# Build
dotnet build
# Run tests
dotnet test
# Run example
dotnet run --project examples/BasicUsage.csproj# Run all tests
dotnet test
# Run tests with verbose output
dotnet test --verbosity normal
# Run specific test class
dotnet test --filter "FullyQualifiedName~CircularReferences"Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Add tests for your changes
- Ensure all tests pass (
dotnet test) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the Unlicense - see the LICENSE file for details.
- GitHub Repository
- Links Notation Specification
- NuGet Package (C#)
- Python Implementation
- JavaScript Implementation
This project is built on top of the Link.Foundation.Links.Notation library.