diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml new file mode 100644 index 0000000..13418ad --- /dev/null +++ b/.github/workflows/csharp.yml @@ -0,0 +1,324 @@ +name: C# CI/CD + +on: + push: + branches: + - main + paths: + - 'csharp/**' + - '.github/workflows/csharp.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'csharp/**' + - '.github/workflows/csharp.yml' + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + # Linting and formatting + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ./csharp + run: dotnet restore + + - name: Check formatting + working-directory: ./csharp + run: dotnet format --verify-no-changes --verbosity diagnostic + + - name: Build with warnings as errors + working-directory: ./csharp + run: dotnet build --configuration Release --no-restore /warnaserror + + # Test matrix: .NET on multiple OS + test: + name: Test (.NET on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Setup .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 --configuration Release --no-restore + + - name: Run tests + working-directory: ./csharp + run: dotnet test --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Run example + working-directory: ./csharp/examples + run: dotnet run + + - name: Upload coverage (Ubuntu only) + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: false + + # Build NuGet package + build: + name: Build Package + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + + - name: Setup .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 --configuration Release --no-restore + + - name: Pack NuGet package + working-directory: ./csharp + run: dotnet pack --configuration Release --no-build --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: csharp/artifacts/*.nupkg + + # Check for changeset in PRs + changeset-check: + name: Changeset Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Check for changeset + working-directory: ./csharp + run: | + # Skip changeset check for automated release PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-manual-release-"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Get list of changeset files (excluding README.md and config.json) + CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + + # Get changed files in PR + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + # Check if any source files changed (excluding docs and config) + SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^csharp/(src/|tests/|examples/)" | wc -l) + + if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$CHANGESET_COUNT" -eq 0 ]; then + echo "::warning::No changeset found. Please add a changeset in csharp/.changeset/" + echo "" + echo "To create a changeset:" + echo " cd csharp/.changeset" + echo " Create a file: YYYYMMDD_HHMMSS_description.md" + echo "" + echo "See csharp/.changeset/README.md for more information." + # Note: This is a warning, not a failure, to allow flexibility + exit 0 + fi + + echo "βœ“ Changeset check passed" + + # Automatic release on push to main (if version changed) + auto-release: + name: Auto Release + needs: [lint, test, build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Check if version changed + id: version_check + working-directory: ./csharp + run: | + # Get current version from csproj + CURRENT_VERSION=$(grep -Po '(?<=)[^<]*' src/Lino.Objects.Codec/Lino.Objects.Codec.csproj) + echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + + # Check if tag exists + if git rev-parse "csharp-v$CURRENT_VERSION" >/dev/null 2>&1; then + echo "Tag csharp-v$CURRENT_VERSION already exists, skipping release" + echo "should_release=false" >> $GITHUB_OUTPUT + else + echo "New version detected: $CURRENT_VERSION" + echo "should_release=true" >> $GITHUB_OUTPUT + fi + + - name: Download artifacts + if: steps.version_check.outputs.should_release == 'true' + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: csharp/artifacts/ + + - name: Publish to NuGet + if: steps.version_check.outputs.should_release == 'true' + working-directory: ./csharp + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY not set, skipping publish to NuGet" + else + dotnet nuget push artifacts/*.nupkg \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + fi + + - name: Create GitHub Release + if: steps.version_check.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: ./csharp + run: | + node scripts/create-github-release.mjs \ + --version "${{ steps.version_check.outputs.current_version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "csharp-v" + + # Manual release via workflow_dispatch + manual-release: + name: Manual Release + needs: [lint, test, build] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Version and commit + id: version + working-directory: ./csharp + run: | + node scripts/version-and-commit.mjs \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" + + - name: Build release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + working-directory: ./csharp + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --configuration Release --output ./artifacts + + - name: Publish to NuGet + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + working-directory: ./csharp + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY not set, skipping publish to NuGet" + else + dotnet nuget push artifacts/*.nupkg \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + fi + + - name: Create GitHub Release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: ./csharp + run: | + node scripts/create-github-release.mjs \ + --version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "csharp-v" diff --git a/README.md b/README.md index edc8ce6..813117d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ # lino-objects-codec -[![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) +[![JS CI](https://github.com/link-foundation/lino-objects-codec/actions/workflows/js.yml/badge.svg)](https://github.com/link-foundation/lino-objects-codec/actions/workflows/js.yml) +[![Python CI](https://github.com/link-foundation/lino-objects-codec/actions/workflows/python.yml/badge.svg)](https://github.com/link-foundation/lino-objects-codec/actions/workflows/python.yml) +[![Rust CI](https://github.com/link-foundation/lino-objects-codec/actions/workflows/rust.yml/badge.svg)](https://github.com/link-foundation/lino-objects-codec/actions/workflows/rust.yml) +[![C# CI](https://github.com/link-foundation/lino-objects-codec/actions/workflows/csharp.yml/badge.svg)](https://github.com/link-foundation/lino-objects-codec/actions/workflows/csharp.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 **Python**, **JavaScript**, and **Rust** with identical functionality and API design. +Universal serialization library to encode/decode objects to/from Links Notation format. Available in **Python**, **JavaScript**, **Rust**, and **C#** with identical functionality and API design. ## 🌍 Multi-Language Support @@ -12,6 +15,7 @@ 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+ - **[Rust](rust/)** - Full implementation for Rust 1.70+ +- **[C#](csharp/)** - Full implementation for .NET 8.0+ All implementations share the same design philosophy and provide feature parity. @@ -22,6 +26,7 @@ All implementations share the same design philosophy and provide feature parity. - **Python**: `None`, `bool`, `int`, `float`, `str`, `list`, `dict` - **JavaScript**: `null`, `undefined`, `boolean`, `number`, `string`, `Array`, `Object` - **Rust**: `LinoValue` enum with `Null`, `Bool`, `Int`, `Float`, `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 @@ -86,6 +91,27 @@ let decoded = decode(&encoded).unwrap(); assert_eq!(decoded, data); ``` +### 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 ``` @@ -104,6 +130,11 @@ assert_eq!(decoded, data); β”‚ β”œβ”€β”€ src/ # Source code β”‚ β”œβ”€β”€ examples/ # Usage examples β”‚ └── README.md # Rust-specific docs +β”œβ”€β”€ csharp/ # C# implementation +β”‚ β”œβ”€β”€ src/ # Source code +β”‚ β”œβ”€β”€ tests/ # Test suite +β”‚ β”œβ”€β”€ examples/ # Usage examples +β”‚ └── README.md # C#-specific docs └── README.md # This file ``` @@ -114,6 +145,7 @@ For detailed documentation, API reference, and examples, see: - **[Python Documentation](python/README.md)** - **[JavaScript Documentation](js/README.md)** - **[Rust Documentation](rust/README.md)** +- **[C# Documentation](csharp/README.md)** ## Usage Examples @@ -143,6 +175,28 @@ const decoded = decode(encode(arr)); console.log(decoded[3] === decoded); // true - Reference preserved ``` +**Rust:** +```rust +use lino_objects_codec::{encode, decode, LinoValue}; + +// Self-referencing structures are handled via object IDs +let data = LinoValue::array([LinoValue::Int(1), LinoValue::Int(2)]); +let encoded = encode(&data); +let decoded = decode(&encoded).unwrap(); +// Reference semantics preserved through encoding/decoding +``` + +**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:** @@ -169,6 +223,39 @@ const data = { console.log(JSON.stringify(decode(encode(data))) === JSON.stringify(data)); ``` +**Rust:** +```rust +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)), + ])), +]); +assert_eq!(decode(&encode(&data)).unwrap(), 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: @@ -217,6 +304,15 @@ cargo test cargo run --example basic_usage ``` +### 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. @@ -240,6 +336,7 @@ This project is licensed under the Unlicense - see the [LICENSE](LICENSE) file f - [PyPI Package](https://pypi.org/project/lino-objects-codec/) (Python) - [npm Package](https://www.npmjs.com/package/lino-objects-codec/) (JavaScript) - [crates.io Package](https://crates.io/crates/lino-objects-codec/) (Rust) +- [NuGet Package](https://www.nuget.org/packages/Lino.Objects.Codec/) (C#) ## Acknowledgments diff --git a/csharp/.changeset/README.md b/csharp/.changeset/README.md new file mode 100644 index 0000000..10e7ca6 --- /dev/null +++ b/csharp/.changeset/README.md @@ -0,0 +1,74 @@ +# Changesets for C# Package + +This folder contains changelog fragments for the C# implementation of lino-objects-codec. + +## What is a Changeset? + +A changeset is a markdown file that describes a change you've made. Each PR that introduces user-facing changes should include a changeset file. + +## Creating a Changeset + +Create a new markdown file in this directory with the following naming pattern: + +``` +YYYYMMDD_HHMMSS_description.md +``` + +For example: `20251231_120000_add_feature.md` + +### File Format + +Each changeset file should have YAML frontmatter with the package name and version bump type, followed by a description: + +```markdown +--- +'Lino.Objects.Codec': patch +--- + +Description of the changes made in this PR. +``` + +### Version Bump Types + +- **patch**: Bug fixes and minor changes (0.0.X) +- **minor**: New features that are backward compatible (0.X.0) +- **major**: Breaking changes (X.0.0) + +## When to Create a Changeset + +Create a changeset when: + +- Adding new features +- Fixing bugs +- Making breaking changes +- Any change that affects users of the package + +You don't need a changeset for: + +- Documentation-only changes +- Internal refactoring that doesn't affect the API +- Test improvements + +## Release Process + +When changes are merged to main: + +1. All changeset files are collected +2. The highest priority version bump is selected (major > minor > patch) +3. The version in the .csproj file is updated +4. CHANGELOG.md is updated with all changeset descriptions +5. A GitHub release is created +6. The package is published to NuGet + +## Example Changeset + +```markdown +--- +'Lino.Objects.Codec': minor +--- + +Add support for encoding DateTimeOffset values. + +This feature allows users to serialize DateTimeOffset objects, which are +automatically converted to ISO 8601 format during encoding. +``` diff --git a/csharp/.changeset/config.json b/csharp/.changeset/config.json new file mode 100644 index 0000000..06e48b1 --- /dev/null +++ b/csharp/.changeset/config.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": false, + "commit": false, + "baseBranch": "main", + "access": "public" +} 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/CHANGELOG.md b/csharp/CHANGELOG.md new file mode 100644 index 0000000..6dc952d --- /dev/null +++ b/csharp/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2024-12-31 + +### Added + +- Initial C# implementation of lino-objects-codec +- `ObjectCodec` class with `Encode()` and `Decode()` methods +- Support for basic types: `null`, `bool`, `int`, `long`, `float`, `double`, `string` +- Support for collections: `List`, `Dictionary` +- Circular reference support with preserved object identity +- Base64 encoding for strings (full UTF-8 support) +- Special float values: `NaN`, `Infinity`, `-Infinity` +- Thread-safe static `Codec` class for easy access +- Comprehensive test suite (69 tests) +- Usage examples 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/scripts/create-github-release.mjs b/csharp/scripts/create-github-release.mjs new file mode 100644 index 0000000..0f39811 --- /dev/null +++ b/csharp/scripts/create-github-release.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +/** + * Create GitHub Release from CHANGELOG.md for C# package + * Usage: node scripts/create-github-release.mjs --version --repository [--tag-prefix ] + * version: Version number (e.g., 1.0.0) + * repository: GitHub repository (e.g., owner/repo) + * tag-prefix: Tag prefix (default: "csharp-v") + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files + */ + +import { readFileSync } from 'fs'; + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import link-foundation libraries +const { $ } = await use('command-stream'); +const { makeConfig } = await use('lino-arguments'); + +// Parse CLI arguments using lino-arguments +const config = makeConfig({ + yargs: ({ yargs, getenv }) => + yargs + .option('version', { + type: 'string', + default: getenv('VERSION', ''), + describe: 'Version number (e.g., 1.0.0)', + }) + .option('repository', { + type: 'string', + default: getenv('REPOSITORY', ''), + describe: 'GitHub repository (e.g., owner/repo)', + }) + .option('tag-prefix', { + type: 'string', + default: getenv('TAG_PREFIX', 'csharp-v'), + describe: 'Tag prefix for the release', + }), +}); + +const { version, repository, tagPrefix } = config; + +if (!version || !repository) { + console.error('Error: Missing required arguments'); + console.error( + 'Usage: node scripts/create-github-release.mjs --version --repository ' + ); + process.exit(1); +} + +const tag = `${tagPrefix}${version}`; + +console.log(`Creating GitHub release for ${tag}...`); + +try { + // Read CHANGELOG.md + let changelog; + try { + changelog = readFileSync('./CHANGELOG.md', 'utf8'); + } catch { + changelog = ''; + } + + // Extract changelog entry for this version + // Read from CHANGELOG.md between this version header and the next version header + const versionHeaderRegex = new RegExp( + `## \\[?${version.replace(/\./g, '\\.')}\\]?[\\s\\S]*?(?=## \\[?\\d|$)` + ); + const match = changelog.match(versionHeaderRegex); + + let releaseNotes = ''; + if (match) { + // Remove the version header itself and trim + releaseNotes = match[0] + .replace(new RegExp(`## \\[?${version.replace(/\./g, '\\.')}\\]?[^\\n]*`), '') + .trim(); + } + + if (!releaseNotes) { + releaseNotes = `Release ${version}`; + } + + // Create release using GitHub API with JSON input + // This avoids shell escaping issues that occur when passing text via command-line arguments + const payload = JSON.stringify({ + tag_name: tag, + name: `C# ${version}`, + body: releaseNotes, + }); + + await $`gh api repos/${repository}/releases -X POST --input -`.run({ + stdin: payload, + }); + + console.log(`\u2705 Created GitHub release: ${tag}`); +} catch (error) { + console.error('Error creating release:', error.message); + process.exit(1); +} diff --git a/csharp/scripts/version-and-commit.mjs b/csharp/scripts/version-and-commit.mjs new file mode 100644 index 0000000..218af67 --- /dev/null +++ b/csharp/scripts/version-and-commit.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env node + +/** + * Version C# package and commit to main + * Usage: node scripts/version-and-commit.mjs --bump-type [--description ] + * bump-type: Version bump type (patch|minor|major) + * description: Optional description for the release + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files + */ + +import { readFileSync, writeFileSync, appendFileSync, readdirSync, unlinkSync } from 'fs'; +import { join } from 'path'; + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import link-foundation libraries +const { $ } = await use('command-stream'); +const { makeConfig } = await use('lino-arguments'); + +// Parse CLI arguments using lino-arguments +const config = makeConfig({ + yargs: ({ yargs, getenv }) => + yargs + .option('bump-type', { + type: 'string', + default: getenv('BUMP_TYPE', ''), + describe: 'Version bump type: major, minor, or patch', + choices: ['major', 'minor', 'patch'], + }) + .option('description', { + type: 'string', + default: getenv('DESCRIPTION', ''), + describe: 'Description for version bump', + }), +}); + +const { bumpType, description } = config; + +// Validation: Ensure bump type is provided +if (!bumpType) { + console.error('Error: --bump-type is required'); + console.error( + 'Usage: node scripts/version-and-commit.mjs --bump-type [--description ]' + ); + process.exit(1); +} + +/** + * Append to GitHub Actions output file + */ +function setOutput(key, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${key}=${value}\n`); + } +} + +/** + * Parse version string into components + */ +function parseVersion(version) { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; +} + +/** + * Bump version based on type + */ +function bumpVersion(version, type) { + const { major, minor, patch } = parseVersion(version); + switch (type) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + default: + throw new Error(`Invalid bump type: ${type}`); + } +} + +/** + * Get version from csproj file + */ +function getVersion() { + const csproj = readFileSync('./src/Lino.Objects.Codec/Lino.Objects.Codec.csproj', 'utf8'); + const match = csproj.match(/([^<]+)<\/Version>/); + if (!match) { + throw new Error('Could not find version in csproj'); + } + return match[1]; +} + +/** + * Set version in csproj file + */ +function setVersion(version) { + const csprojPath = './src/Lino.Objects.Codec/Lino.Objects.Codec.csproj'; + let csproj = readFileSync(csprojPath, 'utf8'); + csproj = csproj.replace(/[^<]+<\/Version>/, `${version}`); + writeFileSync(csprojPath, csproj); +} + +/** + * Get changelog fragments from .changeset + */ +function getChangelogFragments() { + const changesetDir = './.changeset'; + try { + const files = readdirSync(changesetDir); + return files + .filter((f) => f.endsWith('.md') && f !== 'README.md') + .map((f) => ({ + path: join(changesetDir, f), + content: readFileSync(join(changesetDir, f), 'utf8'), + })); + } catch { + return []; + } +} + +/** + * Parse frontmatter from changeset fragment + */ +function parseFrontmatter(content) { + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) { + return { frontmatter: {}, body: content }; + } + const frontmatter = {}; + match[1].split('\n').forEach((line) => { + const colonIdx = line.indexOf(':'); + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim().replace(/^'|'$/g, ''); + const value = line.slice(colonIdx + 1).trim(); + frontmatter[key] = value; + } + }); + return { frontmatter, body: match[2] }; +} + +/** + * Get highest bump type from fragments + */ +function getHighestBumpType(fragments) { + const priority = { patch: 1, minor: 2, major: 3 }; + let highest = 'patch'; + for (const fragment of fragments) { + const { frontmatter } = parseFrontmatter(fragment.content); + const bump = frontmatter['Lino.Objects.Codec'] || frontmatter.bump || 'patch'; + if ((priority[bump] || 0) > priority[highest]) { + highest = bump; + } + } + return highest; +} + +/** + * Collect changelog fragments into CHANGELOG.md + */ +function collectChangelog(version, fragments) { + if (fragments.length === 0) { + return; + } + + // Collect all fragment bodies + const entries = fragments + .map((f) => parseFrontmatter(f.content).body.trim()) + .filter((body) => body.length > 0) + .join('\n\n'); + + // Get current date + const date = new Date().toISOString().split('T')[0]; + + // Create new version entry + const newEntry = `## [${version}] - ${date}\n\n${entries}`; + + // Read existing CHANGELOG.md + let changelog = ''; + try { + changelog = readFileSync('./CHANGELOG.md', 'utf8'); + } catch { + changelog = '# Changelog\n\n'; + } + + // Insert new entry after [Unreleased] section or at the top + const unreleasedMatch = changelog.match(/## \[Unreleased\][^\n]*\n/); + if (unreleasedMatch) { + const insertPos = unreleasedMatch.index + unreleasedMatch[0].length; + changelog = + changelog.slice(0, insertPos) + '\n' + newEntry + '\n' + changelog.slice(insertPos); + } else { + // Insert after header + const headerMatch = changelog.match(/# Changelog[^\n]*\n/); + if (headerMatch) { + const insertPos = headerMatch.index + headerMatch[0].length; + changelog = + changelog.slice(0, insertPos) + '\n' + newEntry + '\n\n' + changelog.slice(insertPos); + } else { + changelog = '# Changelog\n\n' + newEntry + '\n\n' + changelog; + } + } + + writeFileSync('./CHANGELOG.md', changelog); + + // Delete processed fragments + for (const fragment of fragments) { + unlinkSync(fragment.path); + } +} + +async function main() { + try { + // Configure git + await $`git config user.name "github-actions[bot]"`; + await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + + // Check if remote main has advanced (handles re-runs after partial success) + console.log('Checking for remote changes...'); + await $`git fetch origin main`; + + const localHeadResult = await $`git rev-parse HEAD`.run({ capture: true }); + const localHead = localHeadResult.stdout.trim(); + + const remoteHeadResult = await $`git rev-parse origin/main`.run({ + capture: true, + }); + const remoteHead = remoteHeadResult.stdout.trim(); + + if (localHead !== remoteHead) { + console.log( + `Remote main has advanced (local: ${localHead}, remote: ${remoteHead})` + ); + console.log('This may indicate a previous attempt partially succeeded.'); + + // Get the remote csproj version + const remoteCsprojResult = await $`git show origin/main:csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj`.run({ + capture: true, + }); + const remoteVersionMatch = remoteCsprojResult.stdout.match( + /([^<]+)<\/Version>/ + ); + const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; + + console.log(`Remote version: ${remoteVersion}`); + + // Check if there are changelog fragments to process + const fragments = getChangelogFragments(); + + if (fragments.length === 0) { + console.log('No changelog fragments to process and remote has advanced.'); + console.log( + 'Assuming version bump was already completed in a previous attempt.' + ); + setOutput('version_committed', 'false'); + setOutput('already_released', 'true'); + setOutput('new_version', remoteVersion); + return; + } else { + console.log('Rebasing on remote main to incorporate changes...'); + await $`git rebase origin/main`; + } + } + + // Get current version before bump + const oldVersion = getVersion(); + console.log(`Current version: ${oldVersion}`); + + // Get changelog fragments and determine bump type + const fragments = getChangelogFragments(); + let effectiveBumpType = bumpType; + + if (fragments.length > 0) { + const fragmentBumpType = getHighestBumpType(fragments); + console.log(`Bump type from fragments: ${fragmentBumpType}`); + // Use the higher of the two bump types + const priority = { patch: 1, minor: 2, major: 3 }; + if (priority[fragmentBumpType] > priority[bumpType]) { + effectiveBumpType = fragmentBumpType; + console.log(`Using fragment bump type: ${effectiveBumpType}`); + } + } + + // Calculate new version + const newVersion = bumpVersion(oldVersion, effectiveBumpType); + console.log(`New version: ${newVersion}`); + + // Update csproj + setVersion(newVersion); + console.log('Updated csproj'); + + // Collect changelog fragments + if (fragments.length > 0) { + collectChangelog(newVersion, fragments); + console.log('Collected changelog fragments'); + } else if (description) { + // Add manual description to changelog + const date = new Date().toISOString().split('T')[0]; + const newEntry = `## [${newVersion}] - ${date}\n\n${description}`; + let changelog = ''; + try { + changelog = readFileSync('./CHANGELOG.md', 'utf8'); + } catch { + changelog = '# Changelog\n\n'; + } + const unreleasedMatch = changelog.match(/## \[Unreleased\][^\n]*\n/); + if (unreleasedMatch) { + const insertPos = unreleasedMatch.index + unreleasedMatch[0].length; + changelog = + changelog.slice(0, insertPos) + '\n' + newEntry + '\n' + changelog.slice(insertPos); + } else { + const headerMatch = changelog.match(/# Changelog[^\n]*\n/); + if (headerMatch) { + const insertPos = headerMatch.index + headerMatch[0].length; + changelog = + changelog.slice(0, insertPos) + '\n' + newEntry + '\n\n' + changelog.slice(insertPos); + } + } + writeFileSync('./CHANGELOG.md', changelog); + console.log('Updated CHANGELOG.md with description'); + } + + setOutput('new_version', newVersion); + + // Check if there are changes to commit + const statusResult = await $`git status --porcelain`.run({ capture: true }); + const status = statusResult.stdout.trim(); + + if (status) { + console.log('Changes detected, committing...'); + + // Stage all changes + await $`git add -A`; + + // Commit with version number as message + const commitMessage = `csharp-v${newVersion}`; + await $`git commit -m "${commitMessage}"`; + + // Push directly to main + await $`git push origin main`; + + console.log('\u2705 Version bump committed and pushed to main'); + setOutput('version_committed', 'true'); + } else { + console.log('No changes to commit'); + setOutput('version_committed', 'false'); + } + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +main(); 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 + + + + + + + +