From 7da91efcccd89034cb4378554332ac0bb36d39a1 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Dec 2025 19:00:02 +0100 Subject: [PATCH 1/3] Initial commit with task details Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/lino-objects-codec/issues/9 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c9e6487 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/lino-objects-codec/issues/9 +Your prepared branch: issue-9-bef9e95e65ef +Your prepared working directory: /tmp/gh-issue-solver-1765994400495 + +Proceed. \ No newline at end of file From f1f66a9fafc70ae8b5906d51b8f7259d61749283 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Dec 2025 19:16:12 +0100 Subject: [PATCH 2/3] Add C# implementation of lino-objects-codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds full C# support to the lino-objects-codec library: - Implement ObjectCodec class with encode/decode methods - Support for basic types: null, bool, int, long, float, double, string - Support for collections: List, Dictionary - Full circular reference support with shared object identity preservation - Base64 encoding for strings (UTF-8 support) - Handle special float values: NaN, Infinity, -Infinity - Thread-safe Codec static class - 69 unit tests covering all functionality - Example application demonstrating usage - Documentation with usage examples - CI workflow for automated testing Resolves #9 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/test.yml | 27 + README.md | 72 ++- csharp/.gitignore | 55 ++ csharp/Lino.Objects.Codec.sln | 25 + csharp/README.md | 302 +++++++++ csharp/examples/BasicUsage.cs | 109 ++++ csharp/examples/BasicUsage.csproj | 14 + .../Lino.Objects.Codec.csproj | 22 + csharp/src/Lino.Objects.Codec/ObjectCodec.cs | 611 ++++++++++++++++++ .../BasicTypesTests.cs | 276 ++++++++ .../CircularReferencesTests.cs | 208 ++++++ .../CollectionsTests.cs | 235 +++++++ .../Lino.Objects.Codec.Tests.csproj | 28 + 13 files changed, 1981 insertions(+), 3 deletions(-) create mode 100644 csharp/.gitignore create mode 100644 csharp/Lino.Objects.Codec.sln create mode 100644 csharp/README.md create mode 100644 csharp/examples/BasicUsage.cs create mode 100644 csharp/examples/BasicUsage.csproj create mode 100644 csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj create mode 100644 csharp/src/Lino.Objects.Codec/ObjectCodec.cs create mode 100644 csharp/tests/Lino.Objects.Codec.Tests/BasicTypesTests.cs create mode 100644 csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs create mode 100644 csharp/tests/Lino.Objects.Codec.Tests/CollectionsTests.cs create mode 100644 csharp/tests/Lino.Objects.Codec.Tests/Lino.Objects.Codec.Tests.csproj diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bec5c24..c069e96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,3 +61,30 @@ jobs: - name: Run example working-directory: ./js run: npm run example + + test-csharp: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ./csharp + run: dotnet restore + + - name: Build + working-directory: ./csharp + run: dotnet build --no-restore + + - name: Run tests + working-directory: ./csharp + run: dotnet test --no-build --verbosity normal + + - name: Run example + working-directory: ./csharp + run: dotnet run --project examples/BasicUsage.csproj diff --git a/README.md b/README.md index 7016b05..a34f789 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Tests](https://github.com/link-foundation/lino-objects-codec/actions/workflows/test.yml/badge.svg)](https://github.com/link-foundation/lino-objects-codec/actions/workflows/test.yml) [![Python Version](https://img.shields.io/pypi/pyversions/lino-objects-codec.svg)](https://pypi.org/project/lino-objects-codec/) -Universal serialization library to encode/decode objects to/from Links Notation format. Available in both **Python** and **JavaScript** with identical functionality and API design. +Universal serialization library to encode/decode objects to/from Links Notation format. Available in **Python**, **JavaScript**, and **C#** with identical functionality and API design. ## 🌍 Multi-Language Support @@ -11,8 +11,9 @@ This library provides universal serialization and deserialization with built-in - **[Python](python/)** - Full implementation for Python 3.8+ - **[JavaScript](js/)** - Full implementation for Node.js 18+ +- **[C#](csharp/)** - Full implementation for .NET 8.0+ -Both implementations share the same design philosophy and provide feature parity. +All implementations share the same design philosophy and provide feature parity. ## Features @@ -20,6 +21,7 @@ Both implementations share the same design philosophy and provide feature parity - **Type Support**: Handle all common types in each language: - **Python**: `None`, `bool`, `int`, `float`, `str`, `list`, `dict` - **JavaScript**: `null`, `undefined`, `boolean`, `number`, `string`, `Array`, `Object` + - **C#**: `null`, `bool`, `int`, `long`, `float`, `double`, `string`, `List`, `Dictionary` - Special float/number values: `NaN`, `Infinity`, `-Infinity` - **Circular References**: Automatically detect and preserve circular references - **Object Identity**: Maintain object identity for shared references @@ -63,6 +65,27 @@ const decoded = decode(encoded); console.log(JSON.stringify(decoded) === JSON.stringify(data)); // true ``` +### C# + +```bash +dotnet add package Lino.Objects.Codec +``` + +```csharp +using Lino.Objects.Codec; + +// Encode and decode +var data = new Dictionary +{ + { "name", "Alice" }, + { "age", 30 }, + { "active", true } +}; +var encoded = Codec.Encode(data); +var decoded = Codec.Decode(encoded) as Dictionary; +Console.WriteLine(decoded?["name"]); // Alice +``` + ## Repository Structure ``` @@ -77,6 +100,11 @@ console.log(JSON.stringify(decoded) === JSON.stringify(data)); // true β”‚ β”œβ”€β”€ tests/ # Test suite β”‚ β”œβ”€β”€ examples/ # Usage examples β”‚ └── README.md # JavaScript-specific docs +β”œβ”€β”€ csharp/ # C# implementation +β”‚ β”œβ”€β”€ src/ # Source code +β”‚ β”œβ”€β”€ tests/ # Test suite +β”‚ β”œβ”€β”€ examples/ # Usage examples +β”‚ └── README.md # C#-specific docs └── README.md # This file ``` @@ -86,10 +114,11 @@ For detailed documentation, API reference, and examples, see: - **[Python Documentation](python/README.md)** - **[JavaScript Documentation](js/README.md)** +- **[C# Documentation](csharp/README.md)** ## Usage Examples -Both implementations support the same features with language-appropriate syntax: +All implementations support the same features with language-appropriate syntax: ### Circular References @@ -115,6 +144,17 @@ const decoded = decode(encode(arr)); console.log(decoded[3] === decoded); // true - Reference preserved ``` +**C#:** +```csharp +using Lino.Objects.Codec; + +// Self-referencing list +var lst = new List(); +lst.Add(lst); +var decoded = Codec.Decode(Codec.Encode(lst)) as List; +Console.WriteLine(ReferenceEquals(decoded, decoded?[0])); // True - Reference preserved +``` + ### Complex Nested Structures **Python:** @@ -141,6 +181,22 @@ const data = { console.log(JSON.stringify(decode(encode(data))) === JSON.stringify(data)); ``` +**C#:** +```csharp +var data = new Dictionary +{ + { + "users", new List + { + new Dictionary { { "id", 1 }, { "name", "Alice" } }, + new Dictionary { { "id", 2 }, { "name", "Bob" } } + } + }, + { "metadata", new Dictionary { { "version", 1 }, { "count", 2 } } } +}; +var decoded = Codec.Decode(Codec.Encode(data)); +``` + ## How It Works The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. Each object is encoded as a Link with type information: @@ -181,6 +237,15 @@ npm test npm run example ``` +### C# + +```bash +cd csharp +dotnet build +dotnet test +dotnet run --project examples/BasicUsage.csproj +``` + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. @@ -203,6 +268,7 @@ This project is licensed under the Unlicense - see the [LICENSE](LICENSE) file f - [Links Notation Specification](https://github.com/link-foundation/links-notation) - [PyPI Package](https://pypi.org/project/lino-objects-codec/) (Python) - [npm Package](https://www.npmjs.com/package/lino-objects-codec/) (JavaScript) +- [NuGet Package](https://www.nuget.org/packages/Lino.Objects.Codec/) (C#) ## Acknowledgments diff --git a/csharp/.gitignore b/csharp/.gitignore new file mode 100644 index 0000000..8b786e9 --- /dev/null +++ b/csharp/.gitignore @@ -0,0 +1,55 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.sln.docstates + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +**/packages.lock.json + +# Test results +[Tt]est[Rr]esult*/ +[Tt]est[Rr]un*.trx +*.coverage +*.coveragexml +*.dotCover + +# Build outputs +*.dll +*.exe +*.pdb +!TestResults/ + +# User-specific files +*.rsuser +*.suo +*.user +*.sln.docstates + +# JetBrains Rider +.idea/ +*.sln.iml + +# macOS +.DS_Store diff --git a/csharp/Lino.Objects.Codec.sln b/csharp/Lino.Objects.Codec.sln new file mode 100644 index 0000000..808dea5 --- /dev/null +++ b/csharp/Lino.Objects.Codec.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lino.Objects.Codec", "src\Lino.Objects.Codec\Lino.Objects.Codec.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lino.Objects.Codec.Tests", "tests\Lino.Objects.Codec.Tests\Lino.Objects.Codec.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/csharp/README.md b/csharp/README.md new file mode 100644 index 0000000..5e424a8 --- /dev/null +++ b/csharp/README.md @@ -0,0 +1,302 @@ +# lino-objects-codec (C#) + +A C# library for working with Links Notation format. This library provides universal serialization/deserialization for C# objects with circular reference support. + +## Features + +- **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`, `Dictionary` + - Special float values: `NaN`, `Infinity`, `-Infinity` +- **Circular References**: Automatically detect and preserve circular references +- **Object Identity**: Maintain object identity for shared references +- **UTF-8 Support**: Full Unicode string support using base64 encoding +- **Simple API**: Easy-to-use `Codec.Encode()` and `Codec.Decode()` functions +- **Thread Safe**: Each operation uses a fresh codec instance + +## Installation + +### Package Manager + +```text +Install-Package Lino.Objects.Codec +``` + +### .NET CLI + +```bash +dotnet add package Lino.Objects.Codec +``` + +### PackageReference + +```xml + +``` + +## Quick Start + +```csharp +using Lino.Objects.Codec; + +// Encode basic types +var encoded = Codec.Encode(new Dictionary +{ + { "name", "Alice" }, + { "age", 30 }, + { "active", true } +}); +Console.WriteLine(encoded); +// Output: (dict ((str bmFtZQ==) (str QWxpY2U=)) ((str YWdl) (int 30)) ((str YWN0aXZl) (bool True))) + +// Decode back to C# object +var decoded = Codec.Decode(encoded) as Dictionary; +Console.WriteLine($"Name: {decoded?["name"]}, Age: {decoded?["age"]}"); +// Output: Name: Alice, Age: 30 +``` + +## Usage Examples + +### Basic Types + +```csharp +using 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\nstring +``` + +### Collections + +```csharp +using Lino.Objects.Codec; + +// Lists +var list = new List { 1, 2, 3, "hello", true, null }; +var encoded = Codec.Encode(list); +var decoded = Codec.Decode(encoded) as List; +// decoded contains [1, 2, 3, "hello", true, null] + +// Nested lists +var nested = new List +{ + new List { 1, 2 }, + new List { 3, 4 }, + new List { 5, new List { 6, 7 } } +}; +decoded = Codec.Decode(Codec.Encode(nested)) as List; + +// Dictionaries +var person = new Dictionary +{ + { "name", "Bob" }, + { "age", 25 }, + { "email", "bob@example.com" } +}; +decoded = Codec.Decode(Codec.Encode(person)); + +// Complex nested structures +var complexData = new Dictionary +{ + { + "users", new List + { + new Dictionary { { "id", 1 }, { "name", "Alice" } }, + new Dictionary { { "id", 2 }, { "name", "Bob" } } + } + }, + { + "metadata", new Dictionary + { + { "version", 1 }, + { "count", 2 } + } + } +}; +decoded = Codec.Decode(Codec.Encode(complexData)); +``` + +### Circular References + +The library automatically handles circular references and shared objects: + +```csharp +using Lino.Objects.Codec; + +// Self-referencing list +var selfRef = new List(); +selfRef.Add(selfRef); // Circular reference +var encoded = Codec.Encode(selfRef); +// Output: (obj_0: list obj_0) +var decoded = Codec.Decode(encoded) as List; +Console.WriteLine(ReferenceEquals(decoded, decoded?[0])); // True - Reference preserved + +// Self-referencing dictionary +var selfRefDict = new Dictionary(); +selfRefDict["self"] = selfRefDict; // Circular reference +encoded = Codec.Encode(selfRefDict); +// Output: (obj_0: dict ((str c2VsZg==) obj_0)) +var decodedDict = Codec.Decode(encoded) as Dictionary; +Console.WriteLine(ReferenceEquals(decodedDict, decodedDict?["self"])); // True + +// Shared references +var shared = new Dictionary { { "shared", "data" } }; +var container = new Dictionary +{ + { "first", shared }, + { "second", shared } +}; +encoded = Codec.Encode(container); +var decodedContainer = Codec.Decode(encoded) as Dictionary; +// Both references point to the same object +Console.WriteLine(ReferenceEquals(decodedContainer?["first"], decodedContainer?["second"])); // True + +// Complex circular structure (tree with back-references) +var root = new Dictionary { { "name", "root" }, { "children", new List() } }; +var child = new Dictionary { { "name", "child" }, { "parent", root } }; +((List)root["children"]!).Add(child); +encoded = Codec.Encode(root); +var decodedRoot = Codec.Decode(encoded) as Dictionary; +var decodedChild = ((List)decodedRoot?["children"]!)[0] as Dictionary; +Console.WriteLine(ReferenceEquals(decodedRoot, decodedChild?["parent"])); // True +``` + +## How It Works + +The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. Each C# object is encoded as a Link with type information: + +- Basic types are encoded with type markers: `(int 42)`, `(str SGVsbG8=)`, `(bool True)` +- Strings are base64-encoded to handle special characters and newlines +- Collections with self-references use built-in links notation self-reference syntax: + - **Format**: `(obj_id: type content...)` + - **Example**: `(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}` +- Simple collections without shared references use format: `(list item1 item2 ...)` or `(dict (key val) ...)` +- Circular references use direct object ID references: `obj_0` (without the `ref` keyword) + +This approach allows for: +- Universal representation of object graphs +- Preservation of object identity +- Natural handling of circular references using built-in links notation syntax +- Cross-language compatibility with Python and JavaScript implementations + +## API Reference + +### Static Methods + +#### `Codec.Encode(object? obj)` + +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 + +#### `Codec.Decode(string notation)` + +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 + +### ObjectCodec Class + +The main codec class that performs encoding and decoding. The static `Codec` class creates a new instance for each operation to ensure thread safety. + +```csharp +using Lino.Objects.Codec; + +var codec = new ObjectCodec(); +var encoded = codec.Encode(new List { 1, 2, 3 }); +var decoded = codec.Decode(encoded); +``` + +## Development + +### Setup + +```bash +# 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 +``` + +### Running Tests + +```bash +# Run all tests +dotnet test + +# Run tests with verbose output +dotnet test --verbosity normal + +# Run specific test class +dotnet test --filter "FullyQualifiedName~CircularReferences" +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Add tests for your changes +4. Ensure all tests pass (`dotnet test`) +5. Commit your changes (`git commit -m 'Add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request + +## License + +This project is licensed under the Unlicense - see the [LICENSE](../LICENSE) file for details. + +## Links + +- [GitHub Repository](https://github.com/link-foundation/lino-objects-codec) +- [Links Notation Specification](https://github.com/link-foundation/links-notation) +- [NuGet Package](https://www.nuget.org/packages/Lino.Objects.Codec/) (C#) +- [Python Implementation](../python/) +- [JavaScript Implementation](../js/) + +## Acknowledgments + +This project is built on top of the [Link.Foundation.Links.Notation](https://www.nuget.org/packages/Link.Foundation.Links.Notation/) library. diff --git a/csharp/examples/BasicUsage.cs b/csharp/examples/BasicUsage.cs new file mode 100644 index 0000000..17d7c8f --- /dev/null +++ b/csharp/examples/BasicUsage.cs @@ -0,0 +1,109 @@ +// Basic usage example for lino-objects-codec C# implementation. + +using System; +using System.Collections.Generic; +using Lino.Objects.Codec; + +Console.WriteLine("=== lino-objects-codec C# Basic Usage Example ===\n"); + +// 1. Encode and decode basic types +Console.WriteLine("1. Basic Types:"); +Console.WriteLine($" null: {Codec.Encode(null)}"); +Console.WriteLine($" bool (true): {Codec.Encode(true)}"); +Console.WriteLine($" bool (false): {Codec.Encode(false)}"); +Console.WriteLine($" int: {Codec.Encode(42)}"); +Console.WriteLine($" double: {Codec.Encode(3.14)}"); +Console.WriteLine($" string: {Codec.Encode("Hello, World!")}"); +Console.WriteLine(); + +// 2. Roundtrip a dictionary +Console.WriteLine("2. Dictionary Roundtrip:"); +var data = new Dictionary +{ + { "name", "Alice" }, + { "age", 30 }, + { "active", true } +}; +var encoded = Codec.Encode(data); +Console.WriteLine($" Original: {{name: Alice, age: 30, active: true}}"); +Console.WriteLine($" Encoded: {encoded}"); + +var decoded = Codec.Decode(encoded) as Dictionary; +Console.WriteLine($" Decoded: {{name: {decoded?["name"]}, age: {decoded?["age"]}, active: {decoded?["active"]}}}"); +Console.WriteLine(); + +// 3. Encode a list +Console.WriteLine("3. List Encoding:"); +var list = new List { 1, 2, 3, "four", true }; +encoded = Codec.Encode(list); +Console.WriteLine($" List: [1, 2, 3, \"four\", true]"); +Console.WriteLine($" Encoded: {encoded}"); +Console.WriteLine(); + +// 4. Nested structures +Console.WriteLine("4. Nested Structure:"); +var nested = new Dictionary +{ + { + "users", new List + { + new Dictionary { { "id", 1 }, { "name", "Alice" } }, + new Dictionary { { "id", 2 }, { "name", "Bob" } } + } + }, + { + "metadata", new Dictionary + { + { "version", 1 }, + { "count", 2 } + } + } +}; +encoded = Codec.Encode(nested); +Console.WriteLine($" Encoded: {encoded}"); +Console.WriteLine(); + +// 5. Circular references +Console.WriteLine("5. Circular References:"); + +// Self-referencing list +var selfRef = new List(); +selfRef.Add(selfRef); +encoded = Codec.Encode(selfRef); +Console.WriteLine($" Self-referencing list encoded: {encoded}"); + +var decodedSelfRef = Codec.Decode(encoded) as List; +var isSelfRef = decodedSelfRef != null && ReferenceEquals(decodedSelfRef, decodedSelfRef[0]); +Console.WriteLine($" Reference preserved after decode: {isSelfRef}"); + +// Self-referencing dictionary +var selfRefDict = new Dictionary(); +selfRefDict["self"] = selfRefDict; +encoded = Codec.Encode(selfRefDict); +Console.WriteLine($" Self-referencing dict encoded: {encoded}"); +Console.WriteLine(); + +// 6. Mutual references +Console.WriteLine("6. Mutual References:"); +var list1 = new List { 1, 2 }; +var list2 = new List { 3, 4 }; +list1.Add(list2); +list2.Add(list1); +encoded = Codec.Encode(list1); +Console.WriteLine($" Two lists referencing each other:"); +Console.WriteLine($" {encoded}"); + +var decodedList1 = Codec.Decode(encoded) as List; +var decodedList2 = decodedList1?[2] as List; +var backRef = decodedList2?[2]; +Console.WriteLine($" Circular reference preserved: {ReferenceEquals(decodedList1, backRef)}"); +Console.WriteLine(); + +// 7. Special float values +Console.WriteLine("7. Special Float Values:"); +Console.WriteLine($" NaN: {Codec.Encode(double.NaN)}"); +Console.WriteLine($" Infinity: {Codec.Encode(double.PositiveInfinity)}"); +Console.WriteLine($" -Infinity: {Codec.Encode(double.NegativeInfinity)}"); +Console.WriteLine(); + +Console.WriteLine("=== Example completed successfully! ==="); diff --git a/csharp/examples/BasicUsage.csproj b/csharp/examples/BasicUsage.csproj new file mode 100644 index 0000000..767cd65 --- /dev/null +++ b/csharp/examples/BasicUsage.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj b/csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj new file mode 100644 index 0000000..f1dcedb --- /dev/null +++ b/csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + Lino.Objects.Codec + 0.1.0 + Link Foundation + A library to encode/decode objects to/from links notation + Unlicense + https://github.com/link-foundation/lino-objects-codec + https://github.com/link-foundation/lino-objects-codec.git + links-notation;serialization;codec;object-graph;circular-references + true + + + + + + + diff --git a/csharp/src/Lino.Objects.Codec/ObjectCodec.cs b/csharp/src/Lino.Objects.Codec/ObjectCodec.cs new file mode 100644 index 0000000..df72bf5 --- /dev/null +++ b/csharp/src/Lino.Objects.Codec/ObjectCodec.cs @@ -0,0 +1,611 @@ +// Object encoder/decoder for Links Notation format. + +using System.Runtime.CompilerServices; +using System.Text; +using Link.Foundation.Links.Notation; + +namespace Lino.Objects.Codec; + +/// +/// Codec for encoding/decoding C# objects to/from Links Notation. +/// +public class ObjectCodec +{ + /// Type identifier for null values. + public const string TypeNull = "null"; + /// Type identifier for boolean values. + public const string TypeBool = "bool"; + /// Type identifier for integer values. + public const string TypeInt = "int"; + /// Type identifier for float/double values. + public const string TypeFloat = "float"; + /// Type identifier for string values. + public const string TypeStr = "str"; + /// Type identifier for list/array values. + public const string TypeList = "list"; + /// Type identifier for dictionary/object values. + public const string TypeDict = "dict"; + + private readonly Parser _parser = new(); + + // For tracking object identity during encoding + private Dictionary _encodeMemo = new(ReferenceEqualityComparer.Instance); + private int _encodeCounter = 0; + // For tracking which objects need IDs (referenced multiple times or circularly) + private HashSet _needsId = new(ReferenceEqualityComparer.Instance); + // For storing all definitions during encoding + private List<(string RefId, Link Link)> _allDefinitions = new(); + // For tracking references during decoding + private Dictionary _decodeMemo = new(); + // For storing all links during multi-link decoding + private List> _allLinks = new(); + + /// + /// Create a Link from string parts. + /// + private static Link MakeLink(params string[] parts) + { + // Each part becomes a Link with that id + var values = parts.Select(part => new Link(part)).ToList(); + return new Link(null, values); + } + + /// + /// First pass: identify which objects need IDs (referenced multiple times or circularly). + /// + private void FindObjectsNeedingIds(object? obj, Dictionary? seen = null, List? path = null) + { + seen ??= new Dictionary(ReferenceEqualityComparer.Instance); + path ??= new List(); + + // Only track mutable objects (lists and dictionaries) + if (obj is null || (obj is not IList && obj is not IDictionary)) + { + return; + } + + // If we've seen this object before, it's referenced multiple times or circularly + if (seen.ContainsKey(obj)) + { + _needsId.Add(obj); + // Also mark all objects in the cycle as needing IDs + int cycleStart = path.IndexOf(obj); + if (cycleStart >= 0) + { + // This is a circular reference - mark all objects in the cycle + for (int i = cycleStart; i < path.Count; i++) + { + _needsId.Add(path[i]); + } + } + return; // Don't recurse again + } + + // Mark as seen + seen[obj] = true; + // Add to current path + var newPath = new List(path) { obj }; + + // Recurse into structure + if (obj is IList list) + { + foreach (var item in list) + { + if (item is not null) + { + FindObjectsNeedingIds(item, seen, newPath); + } + } + } + else if (obj is IDictionary dict) + { + foreach (var kvp in dict) + { + FindObjectsNeedingIds(kvp.Value, seen, newPath); + } + } + } + + /// + /// Encode a C# object to Links Notation format. + /// + /// The C# object to encode + /// String representation in Links Notation format + public string Encode(object? obj) + { + // Reset state for each encode operation + _encodeMemo = new Dictionary(ReferenceEqualityComparer.Instance); + _encodeCounter = 0; + _needsId = new HashSet(ReferenceEqualityComparer.Instance); + _allDefinitions = new List<(string, Link)>(); + + // First pass: identify which objects need IDs (referenced multiple times or circularly) + FindObjectsNeedingIds(obj); + + // Encode the object (this populates _allDefinitions) + var mainLink = EncodeValue(obj, depth: 0); + + // If we have additional definitions, output them all as multi-link format + if (_allDefinitions.Count > 0) + { + // The main link should be first + var allLinks = new List> { mainLink }; + // Add all other definitions + foreach (var (refId, link) in _allDefinitions) + { + // Only add if not the main link (avoid duplicates) + if (mainLink.Id is null || mainLink.Id != refId) + { + allLinks.Add(link); + } + } + + // Format as multi-link (newline separated) + return string.Join("\n", allLinks.Select(link => new List> { link }.Format())); + } + + // Single link output + return new List> { mainLink }.Format(); + } + + /// + /// Decode Links Notation format to a C# object. + /// + /// String in Links Notation format + /// Reconstructed C# object + public object? Decode(string notation) + { + // Reset state for each decode operation + _decodeMemo = new Dictionary(); + _allLinks = new List>(); + + var links = _parser.Parse(notation); + if (links is null || links.Count == 0) + { + return null; + } + + // If there are multiple links, store them all for forward reference resolution + if (links.Count > 1) + { + _allLinks = links.ToList(); + // Decode the first link (this will be the main result) + // Forward references will be resolved automatically + return DecodeLink(links[0]); + } + + var link = links[0]; + + // Handle case where format() creates output like (obj_0) which parser wraps + // The parser returns a wrapper Link with no ID, containing the actual Link as first value + if (link.Id is null && link.Values is not null && link.Values.Count == 1 && + link.Values[0].Id is string firstValueId && firstValueId.StartsWith("obj_")) + { + // Extract the actual Link + link = link.Values[0]; + } + + return DecodeLink(link); + } + + /// + /// Encode a value into a Link. + /// + private Link EncodeValue(object? obj, HashSet? visited = null, int depth = 0) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + + // Check if we've seen this object before (for circular references and shared objects) + // Only track mutable objects (lists, dicts) + if (obj is not null && (obj is IList || obj is IDictionary)) + { + if (_encodeMemo.ContainsKey(obj)) + { + // Return a reference to the previously defined object + var refId = _encodeMemo[obj]; + return new Link(refId); + } + + // For mutable objects that need IDs, assign them + if (_needsId.Contains(obj)) + { + // Assign an ID if not already assigned + if (!_encodeMemo.ContainsKey(obj)) + { + var refId = $"obj_{_encodeCounter}"; + _encodeCounter++; + _encodeMemo[obj] = refId; + } + + if (visited.Contains(obj)) + { + // We're in a cycle, create a direct reference + var refId = _encodeMemo[obj]; + return new Link(refId); + } + + // Add to visited set + visited = new HashSet(visited, ReferenceEqualityComparer.Instance) { obj }; + } + } + + // Encode based on type + if (obj is null) + { + return MakeLink(TypeNull); + } + + if (obj is bool boolVal) + { + return MakeLink(TypeBool, boolVal ? "True" : "False"); + } + + if (obj is int intVal) + { + return MakeLink(TypeInt, intVal.ToString()); + } + + if (obj is long longVal) + { + return MakeLink(TypeInt, longVal.ToString()); + } + + if (obj is double doubleVal) + { + // Handle special float values + if (double.IsNaN(doubleVal)) + { + return MakeLink(TypeFloat, "NaN"); + } + if (double.IsPositiveInfinity(doubleVal)) + { + return MakeLink(TypeFloat, "Infinity"); + } + if (double.IsNegativeInfinity(doubleVal)) + { + return MakeLink(TypeFloat, "-Infinity"); + } + return MakeLink(TypeFloat, doubleVal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + if (obj is float floatVal) + { + // Handle special float values + if (float.IsNaN(floatVal)) + { + return MakeLink(TypeFloat, "NaN"); + } + if (float.IsPositiveInfinity(floatVal)) + { + return MakeLink(TypeFloat, "Infinity"); + } + if (float.IsNegativeInfinity(floatVal)) + { + return MakeLink(TypeFloat, "-Infinity"); + } + return MakeLink(TypeFloat, floatVal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + if (obj is string strVal) + { + // Encode strings as base64 to handle special characters, newlines, etc. + var b64Encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(strVal)); + return MakeLink(TypeStr, b64Encoded); + } + + if (obj is IList list) + { + var parts = new List>(); + foreach (var item in list) + { + // Encode each item with increased depth + var itemLink = EncodeValue(item, visited, depth + 1); + parts.Add(itemLink); + } + + // If this list has an ID, use self-reference format: (obj_id: list item1 item2 ...) + if (_encodeMemo.TryGetValue(obj, out var listRefId)) + { + var allValues = new List> { new(TypeList) }; + allValues.AddRange(parts); + // Create the definition with self-reference ID + var definition = new Link(listRefId, allValues); + // Store for multi-link output if not at top level + if (depth > 0) + { + _allDefinitions.Add((listRefId, definition)); + // Return a reference instead of the full definition + return new Link(listRefId); + } + return definition; + } + else + { + // Wrap in a type marker for lists without IDs: (list item1 item2 ...) + var allValues = new List> { new(TypeList) }; + allValues.AddRange(parts); + return new Link(null, allValues); + } + } + + if (obj is IDictionary dict) + { + var parts = new List>(); + foreach (var kvp in dict) + { + // Encode key and value with increased depth + var keyLink = EncodeValue(kvp.Key, visited, depth + 1); + var valueLink = EncodeValue(kvp.Value, visited, depth + 1); + // Create a pair link + var pair = new Link(null, new List> { keyLink, valueLink }); + parts.Add(pair); + } + + // If this dict has an ID, use self-reference format: (obj_id: dict (key val) ...) + if (_encodeMemo.TryGetValue(obj, out var dictRefId)) + { + var allValues = new List> { new(TypeDict) }; + allValues.AddRange(parts); + // Create the definition with self-reference ID + var definition = new Link(dictRefId, allValues); + // Store for multi-link output if not at top level + if (depth > 0) + { + _allDefinitions.Add((dictRefId, definition)); + // Return a reference instead of the full definition + return new Link(dictRefId); + } + return definition; + } + else + { + // Wrap in a type marker for dicts without IDs: (dict (key val) ...) + var allValues = new List> { new(TypeDict) }; + allValues.AddRange(parts); + return new Link(null, allValues); + } + } + + throw new NotSupportedException($"Unsupported type: {obj.GetType()}"); + } + + /// + /// Decode a Link into a C# value. + /// + private object? DecodeLink(Link link) + { + // Check if this is a direct reference to a previously decoded object + // Direct references have an id but no values, or the id refers to an existing object + if (link.Id is not null && _decodeMemo.ContainsKey(link.Id)) + { + return _decodeMemo[link.Id]; + } + + if (link.Values is null || link.Values.Count == 0) + { + // Empty link - this might be a simple id, reference, or empty collection + if (link.Id is not null) + { + // If it's in memo, return the cached object + if (_decodeMemo.TryGetValue(link.Id, out var cached)) + { + return cached; + } + + // If it starts with obj_, check if we have a forward reference in _allLinks + if (link.Id.StartsWith("obj_") && _allLinks.Count > 0) + { + // Look for this ID in the remaining links + foreach (var otherLink in _allLinks) + { + if (otherLink.Id == link.Id) + { + // Found it! Decode it now + return DecodeLink(otherLink); + } + } + + // Not found in links - create empty list as fallback + var fallbackResult = new List(); + _decodeMemo[link.Id] = fallbackResult; + return fallbackResult; + } + + // Otherwise it's just a string ID + return link.Id; + } + return null; + } + + // Check if this link has a self-reference ID (format: obj_0: type ...) + string? selfRefId = null; + if (link.Id is not null && link.Id.StartsWith("obj_")) + { + selfRefId = link.Id; + // If this is a back-reference (already in memo), return it + if (_decodeMemo.TryGetValue(selfRefId, out var existing)) + { + return existing; + } + } + + // Get the type marker from the first value + var firstValue = link.Values[0]; + if (firstValue.Id is null) + { + // Not a type marker we recognize + return null; + } + + var typeMarker = firstValue.Id; + + if (typeMarker == TypeNull) + { + return null; + } + + if (typeMarker == TypeBool) + { + if (link.Values.Count > 1) + { + var boolValue = link.Values[1]; + if (boolValue.Id is not null) + { + return boolValue.Id == "True"; + } + } + return false; + } + + if (typeMarker == TypeInt) + { + if (link.Values.Count > 1) + { + var intValue = link.Values[1]; + if (intValue.Id is not null) + { + return int.Parse(intValue.Id); + } + } + return 0; + } + + if (typeMarker == TypeFloat) + { + if (link.Values.Count > 1) + { + var floatValue = link.Values[1]; + if (floatValue.Id is not null) + { + var valueStr = floatValue.Id; + if (valueStr == "NaN") + { + return double.NaN; + } + if (valueStr == "Infinity") + { + return double.PositiveInfinity; + } + if (valueStr == "-Infinity") + { + return double.NegativeInfinity; + } + return double.Parse(valueStr, System.Globalization.CultureInfo.InvariantCulture); + } + } + return 0.0; + } + + if (typeMarker == TypeStr) + { + if (link.Values.Count > 1) + { + var strValue = link.Values[1]; + if (strValue.Id is not null) + { + var b64Str = strValue.Id; + // Decode from base64 + try + { + var decodedBytes = Convert.FromBase64String(b64Str); + return Encoding.UTF8.GetString(decodedBytes); + } + catch + { + // If decode fails, return the raw value + return b64Str; + } + } + } + return ""; + } + + if (typeMarker == TypeList) + { + // New format with self-reference: (obj_0: list item1 item2 ...) + var startIdx = 1; + var listId = selfRefId; // Use self-reference ID from link.Id if present + + var resultList = new List(); + if (listId is not null) + { + _decodeMemo[listId] = resultList; + } + + for (int i = startIdx; i < link.Values.Count; i++) + { + var itemLink = link.Values[i]; + var decodedItem = DecodeLink(itemLink); + resultList.Add(decodedItem); + } + return resultList; + } + + if (typeMarker == TypeDict) + { + // New format with self-reference: (obj_0: dict (key val) ...) + var startIdx = 1; + var dictId = selfRefId; // Use self-reference ID from link.Id if present + + var resultDict = new Dictionary(); + if (dictId is not null) + { + _decodeMemo[dictId] = resultDict; + } + + for (int i = startIdx; i < link.Values.Count; i++) + { + var pairLink = link.Values[i]; + if (pairLink.Values is not null && pairLink.Values.Count >= 2) + { + var keyLink = pairLink.Values[0]; + var valueLink = pairLink.Values[1]; + + var decodedKey = DecodeLink(keyLink); + var decodedValue = DecodeLink(valueLink); + + if (decodedKey is string keyStr) + { + resultDict[keyStr] = decodedValue; + } + } + } + return resultDict; + } + + // Unknown type marker + throw new InvalidOperationException($"Unknown type marker: {typeMarker}"); + } +} + +/// +/// Comparer that compares objects by reference equality. +/// +internal class ReferenceEqualityComparer : IEqualityComparer +{ + public static readonly ReferenceEqualityComparer Instance = new(); + + public new bool Equals(object? x, object? y) => ReferenceEquals(x, y); + + public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); +} + +/// +/// Convenience functions for encoding/decoding objects. +/// +public static class Codec +{ + /// + /// Encode a C# object to Links Notation format. + /// + /// The C# object to encode + /// String representation in Links Notation format + public static string Encode(object? obj) => new ObjectCodec().Encode(obj); + + /// + /// Decode Links Notation format to a C# object. + /// + /// String in Links Notation format + /// Reconstructed C# object + public static object? Decode(string notation) => new ObjectCodec().Decode(notation); +} diff --git a/csharp/tests/Lino.Objects.Codec.Tests/BasicTypesTests.cs b/csharp/tests/Lino.Objects.Codec.Tests/BasicTypesTests.cs new file mode 100644 index 0000000..f4cf128 --- /dev/null +++ b/csharp/tests/Lino.Objects.Codec.Tests/BasicTypesTests.cs @@ -0,0 +1,276 @@ +// Tests for encoding/decoding basic C# types. + +using Xunit; +using Lino.Objects.Codec; + +namespace Lino.Objects.Codec.Tests; + +/// +/// Tests for null type serialization. +/// +public class NullTypeTests +{ + [Fact] + public void Encode_Null_ReturnsString() + { + var result = Codec.Encode(null); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Decode_EncodedNull_ReturnsNull() + { + var encoded = Codec.Encode(null); + var result = Codec.Decode(encoded); + Assert.Null(result); + } + + [Fact] + public void Roundtrip_Null_PreservesValue() + { + object? original = null; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + Assert.Equal(original, decoded); + } +} + +/// +/// Tests for boolean type serialization. +/// +public class BooleanTests +{ + [Fact] + public void Encode_True_ReturnsString() + { + var result = Codec.Encode(true); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Encode_False_ReturnsString() + { + var result = Codec.Encode(false); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Decode_EncodedTrue_ReturnsTrue() + { + var encoded = Codec.Encode(true); + var result = Codec.Decode(encoded); + Assert.True((bool)result!); + } + + [Fact] + public void Decode_EncodedFalse_ReturnsFalse() + { + var encoded = Codec.Encode(false); + var result = Codec.Decode(encoded); + Assert.False((bool)result!); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Roundtrip_Boolean_PreservesValue(bool value) + { + var encoded = Codec.Encode(value); + var decoded = Codec.Decode(encoded); + Assert.IsType(decoded); + Assert.Equal(value, decoded); + } +} + +/// +/// Tests for integer type serialization. +/// +public class IntegerTests +{ + [Fact] + public void Encode_Zero_ReturnsString() + { + var result = Codec.Encode(0); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Encode_PositiveInt_ReturnsString() + { + var result = Codec.Encode(42); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Encode_NegativeInt_ReturnsString() + { + var result = Codec.Encode(-42); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory] + [InlineData(0)] + [InlineData(42)] + [InlineData(-42)] + [InlineData(999999)] + public void Decode_EncodedInt_ReturnsCorrectValue(int value) + { + var encoded = Codec.Encode(value); + var result = Codec.Decode(encoded); + Assert.IsType(result); + Assert.Equal(value, result); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(-1)] + [InlineData(42)] + [InlineData(-42)] + [InlineData(123456789)] + [InlineData(-123456789)] + public void Roundtrip_Integer_PreservesValue(int value) + { + var encoded = Codec.Encode(value); + var decoded = Codec.Decode(encoded); + Assert.IsType(decoded); + Assert.Equal(value, decoded); + } +} + +/// +/// Tests for float/double type serialization. +/// +public class FloatTests +{ + [Fact] + public void Encode_Float_ReturnsString() + { + var result = Codec.Encode(3.14); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Decode_EncodedFloat_ReturnsCorrectValue() + { + var encoded = Codec.Encode(3.14); + var result = Codec.Decode(encoded); + Assert.IsType(result); + Assert.Equal(3.14, (double)result!, 10); + } + + [Theory] + [InlineData(0.0)] + [InlineData(1.0)] + [InlineData(-1.0)] + [InlineData(3.14)] + [InlineData(-3.14)] + [InlineData(0.123456789)] + [InlineData(-999.999)] + public void Roundtrip_Float_PreservesValue(double value) + { + var encoded = Codec.Encode(value); + var decoded = Codec.Decode(encoded); + Assert.IsType(decoded); + Assert.Equal(value, (double)decoded!, 10); + } + + [Fact] + public void Roundtrip_PositiveInfinity_PreservesValue() + { + var encoded = Codec.Encode(double.PositiveInfinity); + var decoded = Codec.Decode(encoded); + Assert.Equal(double.PositiveInfinity, decoded); + } + + [Fact] + public void Roundtrip_NegativeInfinity_PreservesValue() + { + var encoded = Codec.Encode(double.NegativeInfinity); + var decoded = Codec.Decode(encoded); + Assert.Equal(double.NegativeInfinity, decoded); + } + + [Fact] + public void Roundtrip_NaN_PreservesValue() + { + var encoded = Codec.Encode(double.NaN); + var decoded = Codec.Decode(encoded); + Assert.True(double.IsNaN((double)decoded!)); + } +} + +/// +/// Tests for string type serialization. +/// +public class StringTests +{ + [Fact] + public void Encode_EmptyString_ReturnsString() + { + var result = Codec.Encode(""); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Encode_SimpleString_ReturnsString() + { + var result = Codec.Encode("hello"); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Decode_EncodedString_ReturnsCorrectValue() + { + var encoded = Codec.Encode("hello world"); + var result = Codec.Decode(encoded); + Assert.Equal("hello world", result); + Assert.IsType(result); + } + + [Theory] + [InlineData("")] + [InlineData("hello")] + [InlineData("hello world")] + [InlineData("Hello, World!")] + [InlineData("multi\nline\nstring")] + [InlineData("tab\tseparated")] + [InlineData("special chars: @#$%^&*()")] + public void Roundtrip_String_PreservesValue(string value) + { + var encoded = Codec.Encode(value); + var decoded = Codec.Decode(encoded); + Assert.IsType(decoded); + Assert.Equal(value, decoded); + } + + [Fact] + public void Roundtrip_UnicodeString_PreservesValue() + { + var value = "unicode: δ½ ε₯½δΈ–η•Œ 🌍"; + var encoded = Codec.Encode(value); + var decoded = Codec.Decode(encoded); + Assert.IsType(decoded); + Assert.Equal(value, decoded); + } + + [Theory] + [InlineData("string with 'single quotes'")] + [InlineData("string with \"double quotes\"")] + [InlineData("string with \"both\" 'quotes'")] + public void Roundtrip_StringWithQuotes_PreservesValue(string value) + { + var encoded = Codec.Encode(value); + var decoded = Codec.Decode(encoded); + Assert.Equal(value, decoded); + } +} diff --git a/csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs b/csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs new file mode 100644 index 0000000..be0acab --- /dev/null +++ b/csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs @@ -0,0 +1,208 @@ +// Tests for encoding/decoding objects with circular references. + +using Xunit; +using Lino.Objects.Codec; + +namespace Lino.Objects.Codec.Tests; + +/// +/// Tests for circular reference handling. +/// +public class CircularReferencesTests +{ + [Fact] + public void Roundtrip_SelfReferencingList_PreservesReference() + { + var lst = new List(); + lst.Add(lst); + + var encoded = Codec.Encode(lst); + // Verify correct Links Notation format with built-in self-reference syntax + Assert.Equal("(obj_0: list obj_0)", encoded); + + var decoded = Codec.Decode(encoded); + + // Check that it's a list containing itself + var list = Assert.IsType>(decoded); + Assert.Single(list); + Assert.Same(list, list[0]); + } + + [Fact] + public void Roundtrip_SelfReferencingDict_PreservesReference() + { + var d = new Dictionary(); + d["self"] = d; + + var encoded = Codec.Encode(d); + // Verify correct Links Notation format with built-in self-reference syntax + Assert.Equal("(obj_0: dict ((str c2VsZg==) obj_0))", encoded); + + var decoded = Codec.Decode(encoded); + + // Check that it's a dict containing itself + var dict = Assert.IsType>(decoded); + Assert.Single(dict); + Assert.True(dict.ContainsKey("self")); + Assert.Same(dict, dict["self"]); + } + + [Fact] + public void Roundtrip_MutualReferenceLists_PreservesReferences() + { + var list1 = new List { 1, 2 }; + var list2 = new List { 3, 4 }; + list1.Add(list2); + list2.Add(list1); + + var encoded = Codec.Encode(list1); + // Multi-link format is used to avoid parser bug with nested self-references + var expected = "(obj_0: list (int 1) (int 2) obj_1)\n(obj_1: list (int 3) (int 4) obj_0)"; + Assert.Equal(expected, encoded); + + var decoded = Codec.Decode(encoded); + + // Check the structure + var decodedList = Assert.IsType>(decoded); + Assert.Equal(3, decodedList.Count); + Assert.Equal(1, decodedList[0]); + Assert.Equal(2, decodedList[1]); + + var innerList = Assert.IsType>(decodedList[2]); + Assert.Equal(3, innerList.Count); + Assert.Equal(3, innerList[0]); + Assert.Equal(4, innerList[1]); + // Check circular reference + Assert.Same(decodedList, innerList[2]); + } + + [Fact] + public void Roundtrip_MutualReferenceDicts_PreservesReferences() + { + var dict1 = new Dictionary { { "name", "dict1" } }; + var dict2 = new Dictionary { { "name", "dict2" } }; + dict1["other"] = dict2; + dict2["other"] = dict1; + + var encoded = Codec.Encode(dict1); + var decoded = Codec.Decode(encoded); + + // Check the structure + var decodedDict = Assert.IsType>(decoded); + Assert.Equal("dict1", decodedDict["name"]); + + var otherDict = Assert.IsType>(decodedDict["other"]); + Assert.Equal("dict2", otherDict["name"]); + + // Check circular reference + Assert.Same(decodedDict, otherDict["other"]); + } + + [Fact] + public void Roundtrip_ComplexCircularStructure_PreservesReferences() + { + // Create a tree-like structure with a back reference + var root = new Dictionary { { "name", "root" }, { "children", new List() } }; + var child1 = new Dictionary { { "name", "child1" }, { "parent", root } }; + var child2 = new Dictionary { { "name", "child2" }, { "parent", root } }; + ((List)root["children"]!).Add(child1); + ((List)root["children"]!).Add(child2); + + var encoded = Codec.Encode(root); + var decoded = Codec.Decode(encoded); + + // Check the structure + var decodedRoot = Assert.IsType>(decoded); + Assert.Equal("root", decodedRoot["name"]); + + var children = Assert.IsType>(decodedRoot["children"]); + Assert.Equal(2, children.Count); + + var decodedChild1 = Assert.IsType>(children[0]); + Assert.Equal("child1", decodedChild1["name"]); + + var decodedChild2 = Assert.IsType>(children[1]); + Assert.Equal("child2", decodedChild2["name"]); + + // Check circular references + Assert.Same(decodedRoot, decodedChild1["parent"]); + Assert.Same(decodedRoot, decodedChild2["parent"]); + } + + [Fact] + public void Roundtrip_ListWithMultipleReferencesToSameObject_PreservesIdentity() + { + var shared = new Dictionary { { "shared", "value" } }; + var lst = new List { shared, shared, shared }; + + var encoded = Codec.Encode(lst); + var decoded = Codec.Decode(encoded); + + // Check that all three items reference the same object + var list = Assert.IsType>(decoded); + Assert.Equal(3, list.Count); + Assert.Same(list[0], list[1]); + Assert.Same(list[1], list[2]); + + var decodedShared = Assert.IsType>(list[0]); + Assert.Equal("value", decodedShared["shared"]); + } + + [Fact] + public void Roundtrip_DictWithMultipleReferencesToSameObject_PreservesIdentity() + { + var shared = new List { "shared", "list" }; + var d = new Dictionary + { + { "first", shared }, + { "second", shared }, + { "third", shared } + }; + + var encoded = Codec.Encode(d); + var decoded = Codec.Decode(encoded); + + // Check that all three values reference the same object + var dict = Assert.IsType>(decoded); + Assert.Same(dict["first"], dict["second"]); + Assert.Same(dict["second"], dict["third"]); + + var decodedShared = Assert.IsType>(dict["first"]); + Assert.Equal(2, decodedShared.Count); + Assert.Equal("shared", decodedShared[0]); + Assert.Equal("list", decodedShared[1]); + } + + [Fact] + public void Roundtrip_DeeplyNestedCircularReference_PreservesReference() + { + var level1 = new Dictionary { { "level", 1 } }; + var level2 = new Dictionary { { "level", 2 }, { "parent", level1 } }; + var level3 = new Dictionary { { "level", 3 }, { "parent", level2 } }; + var level4 = new Dictionary { { "level", 4 }, { "parent", level3 } }; + level1["child"] = level2; + level2["child"] = level3; + level3["child"] = level4; + // Create circular reference + level4["root"] = level1; + + var encoded = Codec.Encode(level1); + var decoded = Codec.Decode(encoded); + + // Navigate down the structure + var decodedLevel1 = Assert.IsType>(decoded); + Assert.Equal(1, decodedLevel1["level"]); + + var decodedLevel2 = Assert.IsType>(decodedLevel1["child"]); + Assert.Equal(2, decodedLevel2["level"]); + + var decodedLevel3 = Assert.IsType>(decodedLevel2["child"]); + Assert.Equal(3, decodedLevel3["level"]); + + var decodedLevel4 = Assert.IsType>(decodedLevel3["child"]); + Assert.Equal(4, decodedLevel4["level"]); + + // Check circular reference back to root + Assert.Same(decodedLevel1, decodedLevel4["root"]); + } +} diff --git a/csharp/tests/Lino.Objects.Codec.Tests/CollectionsTests.cs b/csharp/tests/Lino.Objects.Codec.Tests/CollectionsTests.cs new file mode 100644 index 0000000..fafb4f8 --- /dev/null +++ b/csharp/tests/Lino.Objects.Codec.Tests/CollectionsTests.cs @@ -0,0 +1,235 @@ +// Tests for encoding/decoding collections (lists and dictionaries). + +using Xunit; +using Lino.Objects.Codec; + +namespace Lino.Objects.Codec.Tests; + +/// +/// Tests for list serialization. +/// +public class ListTests +{ + [Fact] + public void Encode_EmptyList_ReturnsString() + { + var result = Codec.Encode(new List()); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Decode_EncodedEmptyList_ReturnsEmptyList() + { + var encoded = Codec.Encode(new List()); + var result = Codec.Decode(encoded); + Assert.NotNull(result); + var list = Assert.IsType>(result); + Assert.Empty(list); + } + + [Fact] + public void Roundtrip_SimpleList_PreservesValue() + { + var original = new List { 1, 2, 3 }; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var list = Assert.IsType>(decoded); + Assert.Equal(3, list.Count); + Assert.Equal(1, list[0]); + Assert.Equal(2, list[1]); + Assert.Equal(3, list[2]); + } + + [Fact] + public void Roundtrip_MixedTypeList_PreservesValues() + { + var original = new List { 1, "hello", true, 3.14, null }; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var list = Assert.IsType>(decoded); + Assert.Equal(5, list.Count); + Assert.Equal(1, list[0]); + Assert.Equal("hello", list[1]); + Assert.Equal(true, list[2]); + Assert.Equal(3.14, (double)list[3]!, 10); + Assert.Null(list[4]); + } + + [Fact] + public void Roundtrip_NestedList_PreservesStructure() + { + var original = new List + { + new List { 1, 2 }, + new List { 3, 4 } + }; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var list = Assert.IsType>(decoded); + Assert.Equal(2, list.Count); + + var inner1 = Assert.IsType>(list[0]); + Assert.Equal(2, inner1.Count); + Assert.Equal(1, inner1[0]); + Assert.Equal(2, inner1[1]); + + var inner2 = Assert.IsType>(list[1]); + Assert.Equal(2, inner2.Count); + Assert.Equal(3, inner2[0]); + Assert.Equal(4, inner2[1]); + } +} + +/// +/// Tests for dictionary serialization. +/// +public class DictTests +{ + [Fact] + public void Encode_EmptyDict_ReturnsString() + { + var result = Codec.Encode(new Dictionary()); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void Decode_EncodedEmptyDict_ReturnsEmptyDict() + { + var encoded = Codec.Encode(new Dictionary()); + var result = Codec.Decode(encoded); + Assert.NotNull(result); + var dict = Assert.IsType>(result); + Assert.Empty(dict); + } + + [Fact] + public void Roundtrip_SimpleDict_PreservesValue() + { + var original = new Dictionary + { + { "key1", "value1" }, + { "key2", 42 } + }; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var dict = Assert.IsType>(decoded); + Assert.Equal(2, dict.Count); + Assert.Equal("value1", dict["key1"]); + Assert.Equal(42, dict["key2"]); + } + + [Fact] + public void Roundtrip_MixedValueDict_PreservesValues() + { + var original = new Dictionary + { + { "str", "hello" }, + { "int", 42 }, + { "bool", true }, + { "float", 3.14 }, + { "null", null } + }; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var dict = Assert.IsType>(decoded); + Assert.Equal(5, dict.Count); + Assert.Equal("hello", dict["str"]); + Assert.Equal(42, dict["int"]); + Assert.Equal(true, dict["bool"]); + Assert.Equal(3.14, (double)dict["float"]!, 10); + Assert.Null(dict["null"]); + } + + [Fact] + public void Roundtrip_NestedDict_PreservesStructure() + { + var original = new Dictionary + { + { "name", "test" }, + { + "nested", new Dictionary + { + { "inner", "value" } + } + } + }; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var dict = Assert.IsType>(decoded); + Assert.Equal(2, dict.Count); + Assert.Equal("test", dict["name"]); + + var nested = Assert.IsType>(dict["nested"]); + Assert.Single(nested); + Assert.Equal("value", nested["inner"]); + } + + [Fact] + public void Roundtrip_DictWithListValue_PreservesStructure() + { + var original = new Dictionary + { + { "items", new List { 1, 2, 3 } } + }; + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var dict = Assert.IsType>(decoded); + Assert.Single(dict); + + var items = Assert.IsType>(dict["items"]); + Assert.Equal(3, items.Count); + Assert.Equal(1, items[0]); + Assert.Equal(2, items[1]); + Assert.Equal(3, items[2]); + } +} + +/// +/// Tests for complex nested structures. +/// +public class ComplexStructureTests +{ + [Fact] + public void Roundtrip_ComplexNestedStructure_PreservesAllData() + { + var original = new Dictionary + { + { + "users", new List + { + new Dictionary { { "id", 1 }, { "name", "Alice" } }, + new Dictionary { { "id", 2 }, { "name", "Bob" } } + } + }, + { + "metadata", new Dictionary + { + { "version", 1 }, + { "count", 2 } + } + } + }; + + var encoded = Codec.Encode(original); + var decoded = Codec.Decode(encoded); + var dict = Assert.IsType>(decoded); + Assert.Equal(2, dict.Count); + + var users = Assert.IsType>(dict["users"]); + Assert.Equal(2, users.Count); + + var user1 = Assert.IsType>(users[0]); + Assert.Equal(1, user1["id"]); + Assert.Equal("Alice", user1["name"]); + + var user2 = Assert.IsType>(users[1]); + Assert.Equal(2, user2["id"]); + Assert.Equal("Bob", user2["name"]); + + var metadata = Assert.IsType>(dict["metadata"]); + Assert.Equal(1, metadata["version"]); + Assert.Equal(2, metadata["count"]); + } +} diff --git a/csharp/tests/Lino.Objects.Codec.Tests/Lino.Objects.Codec.Tests.csproj b/csharp/tests/Lino.Objects.Codec.Tests/Lino.Objects.Codec.Tests.csproj new file mode 100644 index 0000000..4b853ee --- /dev/null +++ b/csharp/tests/Lino.Objects.Codec.Tests/Lino.Objects.Codec.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + From 8f52b1fc285265acc8fce034e34b3268f50213af Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Dec 2025 19:18:28 +0100 Subject: [PATCH 3/3] Revert "Initial commit with task details" This reverts commit 7da91efcccd89034cb4378554332ac0bb36d39a1. --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c9e6487..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/lino-objects-codec/issues/9 -Your prepared branch: issue-9-bef9e95e65ef -Your prepared working directory: /tmp/gh-issue-solver-1765994400495 - -Proceed. \ No newline at end of file