From fa934084d674e46817ee54171bce3a1283179dff Mon Sep 17 00:00:00 2001
From: token <61819790+239573049@users.noreply.github.com>
Date: Thu, 6 Nov 2025 16:12:30 +0800
Subject: [PATCH 1/9] Add TOON format encoding and decoding functionality
- Implement StringUtils for string manipulation, including escaping and unescaping.
- Create ValidationShared for key validation and safety checks.
- Introduce ToonDecodeOptions and ToonEncodeOptions for customizable encoding/decoding settings.
- Develop ToonDecoder for parsing TOON format strings into JsonNode objects.
- Implement ToonEncoder for converting data structures into TOON format strings.
- Add ToonFormatException for error handling during parsing and encoding.
- Create unit tests for ToonDecoder and ToonEncoder to ensure functionality and data integrity.
- Remove outdated UnitTest1.cs file.
---
src/ToonFormat/Class1.cs | 35 --
src/ToonFormat/Constants.cs | 93 ++++
.../DoubleNamedFloatToNullConverter.cs | 27 ++
.../SingleNamedFloatToNullConverter.cs | 24 +
src/ToonFormat/Internal/Decode/Decoders.cs | 447 ++++++++++++++++++
src/ToonFormat/Internal/Decode/Parser.cs | 425 +++++++++++++++++
src/ToonFormat/Internal/Decode/Scanner.cs | 190 ++++++++
src/ToonFormat/Internal/Decode/Validation.cs | 150 ++++++
src/ToonFormat/Internal/Encode/Encoders.cs | 445 +++++++++++++++++
src/ToonFormat/Internal/Encode/LineWriter.cs | 70 +++
src/ToonFormat/Internal/Encode/Normalize.cs | 308 ++++++++++++
src/ToonFormat/Internal/Encode/Primitives.cs | 150 ++++++
.../Internal/Shared/LiteralUtils.cs | 45 ++
src/ToonFormat/Internal/Shared/StringUtils.cs | 151 ++++++
.../Internal/Shared/ValidationShared.cs | 95 ++++
src/ToonFormat/Options/ToonDecodeOptions.cs | 20 +
src/ToonFormat/Options/ToonEncodeOptions.cs | 29 ++
src/ToonFormat/ToonDecoder.cs | 197 ++++++++
src/ToonFormat/ToonEncoder.cs | 193 ++++++++
src/ToonFormat/ToonFormat.csproj | 8 +-
src/ToonFormat/ToonFormatException.cs | 145 ++++++
tests/ToonFormat.Tests/ToonDecoderTests.cs | 170 +++++++
tests/ToonFormat.Tests/ToonEncoderTests.cs | 154 ++++++
tests/ToonFormat.Tests/ToonRoundTripTests.cs | 84 ++++
tests/ToonFormat.Tests/UnitTest1.cs | 29 --
25 files changed, 3619 insertions(+), 65 deletions(-)
delete mode 100644 src/ToonFormat/Class1.cs
create mode 100644 src/ToonFormat/Constants.cs
create mode 100644 src/ToonFormat/Internal/Converters/DoubleNamedFloatToNullConverter.cs
create mode 100644 src/ToonFormat/Internal/Converters/SingleNamedFloatToNullConverter.cs
create mode 100644 src/ToonFormat/Internal/Decode/Decoders.cs
create mode 100644 src/ToonFormat/Internal/Decode/Parser.cs
create mode 100644 src/ToonFormat/Internal/Decode/Scanner.cs
create mode 100644 src/ToonFormat/Internal/Decode/Validation.cs
create mode 100644 src/ToonFormat/Internal/Encode/Encoders.cs
create mode 100644 src/ToonFormat/Internal/Encode/LineWriter.cs
create mode 100644 src/ToonFormat/Internal/Encode/Normalize.cs
create mode 100644 src/ToonFormat/Internal/Encode/Primitives.cs
create mode 100644 src/ToonFormat/Internal/Shared/LiteralUtils.cs
create mode 100644 src/ToonFormat/Internal/Shared/StringUtils.cs
create mode 100644 src/ToonFormat/Internal/Shared/ValidationShared.cs
create mode 100644 src/ToonFormat/Options/ToonDecodeOptions.cs
create mode 100644 src/ToonFormat/Options/ToonEncodeOptions.cs
create mode 100644 src/ToonFormat/ToonDecoder.cs
create mode 100644 src/ToonFormat/ToonEncoder.cs
create mode 100644 src/ToonFormat/ToonFormatException.cs
create mode 100644 tests/ToonFormat.Tests/ToonDecoderTests.cs
create mode 100644 tests/ToonFormat.Tests/ToonEncoderTests.cs
create mode 100644 tests/ToonFormat.Tests/ToonRoundTripTests.cs
delete mode 100644 tests/ToonFormat.Tests/UnitTest1.cs
diff --git a/src/ToonFormat/Class1.cs b/src/ToonFormat/Class1.cs
deleted file mode 100644
index 2996b9d..0000000
--- a/src/ToonFormat/Class1.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-namespace Toon.Format;
-
-///
-/// Encodes data structures into TOON format.
-///
-public static class ToonEncoder
-{
- ///
- /// Encodes the specified object into TOON format.
- ///
- /// The object to encode.
- /// A TOON-formatted string representation of the object.
- /// This is a placeholder implementation.
- public static string Encode(object data)
- {
- throw new NotImplementedException("TOON encoding not yet implemented. This is a namespace reservation.");
- }
-}
-
-///
-/// Decodes TOON-formatted strings into data structures.
-///
-public static class ToonDecoder
-{
- ///
- /// Decodes a TOON-formatted string into an object.
- ///
- /// The TOON-formatted string to decode.
- /// The decoded object.
- /// This is a placeholder implementation.
- public static object Decode(string toonString)
- {
- throw new NotImplementedException("TOON decoding not yet implemented. This is a namespace reservation.");
- }
-}
diff --git a/src/ToonFormat/Constants.cs b/src/ToonFormat/Constants.cs
new file mode 100644
index 0000000..e36c52d
--- /dev/null
+++ b/src/ToonFormat/Constants.cs
@@ -0,0 +1,93 @@
+using System;
+
+namespace ToonFormat
+{
+
+ public static class Constants
+ {
+ public const char LIST_ITEM_MARKER = '-';
+
+ public const string LIST_ITEM_PREFIX = "- ";
+
+ // #region Structural characters
+ public const char COMMA = ',';
+ public const char COLON = ':';
+ public const char SPACE = ' ';
+ public const char PIPE = '|';
+ public const char HASH = '#';
+ // #endregion
+
+ // #region Brackets and braces
+ public const char OPEN_BRACKET = '[';
+ public const char CLOSE_BRACKET = ']';
+ public const char OPEN_BRACE = '{';
+ public const char CLOSE_BRACE = '}';
+ // #endregion
+
+ // #region Literals
+ public const string NULL_LITERAL = "null";
+ public const string TRUE_LITERAL = "true";
+ public const string FALSE_LITERAL = "false";
+ // #endregion
+
+ // #region Escape/control characters
+ public const char BACKSLASH = '\\';
+ public const char DOUBLE_QUOTE = '"';
+ public const char NEWLINE = '\n';
+ public const char CARRIAGE_RETURN = '\r';
+ public const char TAB = '\t';
+
+
+ // #region Delimiter defaults and mapping
+ public const ToonDelimiter DEFAULT_DELIMITER_ENUM = ToonDelimiter.COMMA;
+
+ /// Default delimiter character (comma).
+ public const char DEFAULT_DELIMITER_CHAR = COMMA;
+
+ /// Maps delimiter enum values to their specific characters.
+ public static char ToDelimiterChar(ToonDelimiter delimiter) => delimiter switch
+ {
+ ToonDelimiter.COMMA => COMMA,
+ ToonDelimiter.TAB => TAB,
+ ToonDelimiter.PIPE => PIPE,
+ _ => COMMA
+ };
+
+ /// Maps delimiter characters to enum; unknown characters fall back to comma.
+ public static ToonDelimiter FromDelimiterChar(char delimiter) => delimiter switch
+ {
+ COMMA => ToonDelimiter.COMMA,
+ TAB => ToonDelimiter.TAB,
+ PIPE => ToonDelimiter.PIPE,
+ _ => ToonDelimiter.COMMA
+ };
+
+ /// Returns whether the character is a supported delimiter.
+ public static bool IsDelimiterChar(char c) => c == COMMA || c == TAB || c == PIPE;
+
+ /// Returns whether the character is a whitespace character (space or tab).
+ public static bool IsWhitespace(char c) => c == SPACE || c == TAB;
+
+ /// Returns whether the character is a structural character.
+ public static bool IsStructural(char c)
+ => c == COLON || c == OPEN_BRACKET || c == CLOSE_BRACKET || c == OPEN_BRACE || c == CLOSE_BRACE;
+ // #endregion
+ }
+
+ ///
+ /// TOON's unified options configuration, styled to align with System.Text.Json. Used to control indentation,
+ /// delimiters, strict mode, length markers, and underlying JSON behavior.
+ ///
+ public enum ToonDelimiter
+ {
+ /// Comma ,
+ COMMA,
+
+ /// Tab \t
+ TAB,
+
+ /// Pipe |
+ PIPE
+ }
+
+}
diff --git a/src/ToonFormat/Internal/Converters/DoubleNamedFloatToNullConverter.cs b/src/ToonFormat/Internal/Converters/DoubleNamedFloatToNullConverter.cs
new file mode 100644
index 0000000..7e0c6a5
--- /dev/null
+++ b/src/ToonFormat/Internal/Converters/DoubleNamedFloatToNullConverter.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ToonFormat.Internal.Converters
+{
+ ///
+ /// Normalizes double NaN/Infinity to null when writing JSON, keeping original numeric precision otherwise.
+ /// Reading still uses default handling, no special conversion.
+ /// Purpose: Consistent with TS spec (NaN/±Infinity -> null), and provides stable JsonElement for subsequent TOON encoding phase.
+ ///
+ internal sealed class DoubleNamedFloatToNullConverter : JsonConverter
+ {
+ public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => reader.GetDouble();
+
+ public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ {
+ writer.WriteNullValue();
+ return;
+ }
+ writer.WriteNumberValue(value);
+ }
+ }
+}
diff --git a/src/ToonFormat/Internal/Converters/SingleNamedFloatToNullConverter.cs b/src/ToonFormat/Internal/Converters/SingleNamedFloatToNullConverter.cs
new file mode 100644
index 0000000..abd1eff
--- /dev/null
+++ b/src/ToonFormat/Internal/Converters/SingleNamedFloatToNullConverter.cs
@@ -0,0 +1,24 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ToonFormat.Internal.Converters
+{
+ ///
+ /// Normalizes float NaN/Infinity to null when writing JSON; reading keeps default behavior.
+ ///
+ internal sealed class SingleNamedFloatToNullConverter : JsonConverter
+ {
+ public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => reader.GetSingle();
+
+ public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options)
+ {
+ if (float.IsNaN(value) || float.IsInfinity(value))
+ {
+ writer.WriteNullValue();
+ return;
+ }
+ writer.WriteNumberValue(value);
+ }
+ }
+}
diff --git a/src/ToonFormat/Internal/Decode/Decoders.cs b/src/ToonFormat/Internal/Decode/Decoders.cs
new file mode 100644
index 0000000..abf6587
--- /dev/null
+++ b/src/ToonFormat/Internal/Decode/Decoders.cs
@@ -0,0 +1,447 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using ToonFormat.Internal.Shared;
+
+namespace ToonFormat.Internal.Decode
+{
+ ///
+ /// Main decoding functions for converting TOON format to JSON values.
+ /// Aligned with TypeScript decode/decoders.ts
+ ///
+ internal static class Decoders
+ {
+ // #region Entry decoding
+
+ ///
+ /// Decodes TOON content from a line cursor into a JSON value.
+ ///
+ public static JsonNode? DecodeValueFromLines(LineCursor cursor, ResolvedDecodeOptions options)
+ {
+ var first = cursor.Peek();
+ if (first == null)
+ {
+ throw ToonFormatException.Syntax("No content to decode");
+ }
+
+ // Check for root array
+ if (Parser.IsArrayHeaderAfterHyphen(first.Content))
+ {
+ var headerInfo = Parser.ParseArrayHeaderLine(first.Content, Constants.DEFAULT_DELIMITER_CHAR);
+ if (headerInfo != null)
+ {
+ cursor.Advance(); // Move past the header line
+ return DecodeArrayFromHeader(headerInfo.Header, headerInfo.InlineValues, cursor, 0, options);
+ }
+ }
+
+ // Check for single primitive value
+ if (cursor.Length == 1 && !IsKeyValueLine(first))
+ {
+ return Parser.ParsePrimitiveToken(first.Content.Trim());
+ }
+
+ // Default to object
+ return DecodeObject(cursor, 0, options);
+ }
+
+ private static bool IsKeyValueLine(ParsedLine line)
+ {
+ var content = line.Content;
+ // Look for unquoted colon or quoted key followed by colon
+ if (content.StartsWith("\""))
+ {
+ // Quoted key - find the closing quote
+ var closingQuoteIndex = StringUtils.FindClosingQuote(content, 0);
+ if (closingQuoteIndex == -1)
+ return false;
+
+ // Check if colon exists after quoted key (may have array/brace syntax between)
+ return content.Substring(closingQuoteIndex + 1).Contains(Constants.COLON);
+ }
+ else
+ {
+ // Unquoted key - look for first colon not inside quotes
+ return content.Contains(Constants.COLON);
+ }
+ }
+
+ // #endregion
+
+ // #region Object decoding
+
+ private static JsonObject DecodeObject(LineCursor cursor, int baseDepth, ResolvedDecodeOptions options)
+ {
+ var obj = new JsonObject();
+
+ // Detect the actual depth of the first field (may differ from baseDepth in nested structures)
+ int? computedDepth = null;
+
+ while (!cursor.AtEnd())
+ {
+ var line = cursor.Peek();
+ if (line == null || line.Depth < baseDepth)
+ break;
+
+ if (computedDepth == null && line.Depth >= baseDepth)
+ {
+ computedDepth = line.Depth;
+ }
+
+ if (line.Depth == computedDepth)
+ {
+ var (key, value) = DecodeKeyValuePair(line, cursor, computedDepth.Value, options);
+ obj[key] = value;
+ }
+ else
+ {
+ // Different depth (shallower or deeper) - stop object parsing
+ break;
+ }
+ }
+
+ return obj;
+ }
+
+ private class KeyValueDecodeResult
+ {
+ public string Key { get; set; } = string.Empty;
+ public JsonNode? Value { get; set; }
+ public int FollowDepth { get; set; }
+ }
+
+ private static KeyValueDecodeResult DecodeKeyValue(
+ string content,
+ LineCursor cursor,
+ int baseDepth,
+ ResolvedDecodeOptions options)
+ {
+ // Check for array header first (before parsing key)
+ var arrayHeader = Parser.ParseArrayHeaderLine(content, Constants.DEFAULT_DELIMITER_CHAR);
+ if (arrayHeader != null && arrayHeader.Header.Key != null)
+ {
+ var value = DecodeArrayFromHeader(arrayHeader.Header, arrayHeader.InlineValues, cursor, baseDepth, options);
+ // After an array, subsequent fields are at baseDepth + 1 (where array content is)
+ return new KeyValueDecodeResult
+ {
+ Key = arrayHeader.Header.Key,
+ Value = value,
+ FollowDepth = baseDepth + 1
+ };
+ }
+
+ // Regular key-value pair
+ var keyResult = Parser.ParseKeyToken(content, 0);
+ var rest = content.Substring(keyResult.End).Trim();
+
+ // No value after colon - expect nested object or empty
+ if (string.IsNullOrEmpty(rest))
+ {
+ var nextLine = cursor.Peek();
+ if (nextLine != null && nextLine.Depth > baseDepth)
+ {
+ var nested = DecodeObject(cursor, baseDepth + 1, options);
+ return new KeyValueDecodeResult { Key = keyResult.Key, Value = nested, FollowDepth = baseDepth + 1 };
+ }
+ // Empty object
+ return new KeyValueDecodeResult { Key = keyResult.Key, Value = new JsonObject(), FollowDepth = baseDepth + 1 };
+ }
+
+ // Inline primitive value
+ var primitiveValue = Parser.ParsePrimitiveToken(rest);
+ return new KeyValueDecodeResult { Key = keyResult.Key, Value = primitiveValue, FollowDepth = baseDepth + 1 };
+ }
+
+ private static (string key, JsonNode? value) DecodeKeyValuePair(
+ ParsedLine line,
+ LineCursor cursor,
+ int baseDepth,
+ ResolvedDecodeOptions options)
+ {
+ cursor.Advance();
+ var result = DecodeKeyValue(line.Content, cursor, baseDepth, options);
+ return (result.Key, result.Value);
+ }
+
+ // #endregion
+
+ // #region Array decoding
+
+ private static JsonNode DecodeArrayFromHeader(
+ ArrayHeaderInfo header,
+ string? inlineValues,
+ LineCursor cursor,
+ int baseDepth,
+ ResolvedDecodeOptions options)
+ {
+ // Inline primitive array
+ if (inlineValues != null)
+ {
+ // For inline arrays, cursor should already be advanced or will be by caller
+ return new JsonArray(DecodeInlinePrimitiveArray(header, inlineValues, options).ToArray());
+ }
+
+ // For multi-line arrays (tabular or list), the cursor should already be positioned
+ // at the array header line, but we haven't advanced past it yet
+
+ // Tabular array
+ if (header.Fields != null && header.Fields.Count > 0)
+ {
+ var tabularResult = DecodeTabularArray(header, cursor, baseDepth, options);
+ return new JsonArray(tabularResult.Cast().ToArray());
+ }
+
+ // List array
+ var listResult = DecodeListArray(header, cursor, baseDepth, options);
+ return new JsonArray(listResult.ToArray());
+ }
+
+ private static List DecodeInlinePrimitiveArray(
+ ArrayHeaderInfo header,
+ string inlineValues,
+ ResolvedDecodeOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(inlineValues))
+ {
+ Validation.AssertExpectedCount(0, header.Length, "inline array items", options);
+ return new List();
+ }
+
+ var values = Parser.ParseDelimitedValues(inlineValues, header.Delimiter);
+ var primitives = Parser.MapRowValuesToPrimitives(values);
+
+ Validation.AssertExpectedCount(primitives.Count, header.Length, "inline array items", options);
+
+ return primitives;
+ }
+
+ private static List DecodeListArray(
+ ArrayHeaderInfo header,
+ LineCursor cursor,
+ int baseDepth,
+ ResolvedDecodeOptions options)
+ {
+ var items = new List();
+ var itemDepth = baseDepth + 1;
+
+ // Track line range for blank line validation
+ int? startLine = null;
+ int? endLine = null;
+
+ while (!cursor.AtEnd() && items.Count < header.Length)
+ {
+ var line = cursor.Peek();
+ if (line == null || line.Depth < itemDepth)
+ break;
+
+ // Check for list item (with or without space after hyphen)
+ var isListItem = line.Content.StartsWith(Constants.LIST_ITEM_PREFIX) || line.Content == "-";
+
+ if (line.Depth == itemDepth && isListItem)
+ {
+ // Track first and last item line numbers
+ if (startLine == null)
+ startLine = line.LineNumber;
+ endLine = line.LineNumber;
+
+ var item = DecodeListItem(cursor, itemDepth, options);
+ items.Add(item);
+
+ // Update endLine to the current cursor position (after item was decoded)
+ var currentLine = cursor.Current();
+ if (currentLine != null)
+ endLine = currentLine.LineNumber;
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ Validation.AssertExpectedCount(items.Count, header.Length, "list array items", options);
+
+ // In strict mode, check for blank lines inside the array
+ if (options.Strict && startLine != null && endLine != null)
+ {
+ Validation.ValidateNoBlankLinesInRange(
+ startLine.Value,
+ endLine.Value,
+ cursor.GetBlankLines(),
+ options.Strict,
+ "list array"
+ );
+ }
+
+ // In strict mode, check for extra items
+ if (options.Strict)
+ {
+ Validation.ValidateNoExtraListItems(cursor, itemDepth, header.Length);
+ }
+
+ return items;
+ }
+
+ private static List DecodeTabularArray(
+ ArrayHeaderInfo header,
+ LineCursor cursor,
+ int baseDepth,
+ ResolvedDecodeOptions options)
+ {
+ var objects = new List();
+ var rowDepth = baseDepth + 1;
+
+ // Track line range for blank line validation
+ int? startLine = null;
+ int? endLine = null;
+
+ while (!cursor.AtEnd() && objects.Count < header.Length)
+ {
+ var line = cursor.Peek();
+ if (line == null || line.Depth < rowDepth)
+ break;
+
+ if (line.Depth == rowDepth)
+ {
+ // Track first and last row line numbers
+ if (startLine == null)
+ startLine = line.LineNumber;
+ endLine = line.LineNumber;
+
+ cursor.Advance();
+ var values = Parser.ParseDelimitedValues(line.Content, header.Delimiter);
+ Validation.AssertExpectedCount(values.Count, header.Fields!.Count, "tabular row values", options);
+
+ var primitives = Parser.MapRowValuesToPrimitives(values);
+ var obj = new JsonObject();
+
+ for (int i = 0; i < header.Fields!.Count; i++)
+ {
+ obj[header.Fields[i]] = primitives[i];
+ }
+
+ objects.Add(obj);
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ Validation.AssertExpectedCount(objects.Count, header.Length, "tabular rows", options);
+
+ // In strict mode, check for blank lines inside the array
+ if (options.Strict && startLine != null && endLine != null)
+ {
+ Validation.ValidateNoBlankLinesInRange(
+ startLine.Value,
+ endLine.Value,
+ cursor.GetBlankLines(),
+ options.Strict,
+ "tabular array"
+ );
+ }
+
+ // In strict mode, check for extra rows
+ if (options.Strict)
+ {
+ Validation.ValidateNoExtraTabularRows(cursor, rowDepth, header);
+ }
+
+ return objects;
+ }
+
+ // #endregion
+
+ // #region List item decoding
+
+ private static JsonNode? DecodeListItem(
+ LineCursor cursor,
+ int baseDepth,
+ ResolvedDecodeOptions options)
+ {
+ var line = cursor.Next();
+ if (line == null)
+ {
+ throw ToonFormatException.Syntax("Expected list item");
+ }
+
+ // Check for list item (with or without space after hyphen)
+ string afterHyphen;
+
+ // Empty list item should be an empty object
+ if (line.Content == "-")
+ {
+ return new JsonObject();
+ }
+ else if (line.Content.StartsWith(Constants.LIST_ITEM_PREFIX))
+ {
+ afterHyphen = line.Content.Substring(Constants.LIST_ITEM_PREFIX.Length);
+ }
+ else
+ {
+ throw ToonFormatException.Syntax($"Expected list item to start with \"{Constants.LIST_ITEM_PREFIX}\"");
+ }
+
+ // Empty content after list item should also be an empty object
+ if (string.IsNullOrWhiteSpace(afterHyphen))
+ {
+ return new JsonObject();
+ }
+
+ // Check for array header after hyphen
+ if (Parser.IsArrayHeaderAfterHyphen(afterHyphen))
+ {
+ var arrayHeader = Parser.ParseArrayHeaderLine(afterHyphen, Constants.DEFAULT_DELIMITER_CHAR);
+ if (arrayHeader != null)
+ {
+ return DecodeArrayFromHeader(arrayHeader.Header, arrayHeader.InlineValues, cursor, baseDepth, options);
+ }
+ }
+
+ // Check for object first field after hyphen
+ if (Parser.IsObjectFirstFieldAfterHyphen(afterHyphen))
+ {
+ return DecodeObjectFromListItem(line, cursor, baseDepth, options);
+ }
+
+ // Primitive value
+ return Parser.ParsePrimitiveToken(afterHyphen);
+ }
+
+ private static JsonObject DecodeObjectFromListItem(
+ ParsedLine firstLine,
+ LineCursor cursor,
+ int baseDepth,
+ ResolvedDecodeOptions options)
+ {
+ var afterHyphen = firstLine.Content.Substring(Constants.LIST_ITEM_PREFIX.Length);
+ var firstField = DecodeKeyValue(afterHyphen, cursor, baseDepth, options);
+
+ var obj = new JsonObject { [firstField.Key] = firstField.Value };
+
+ // Read subsequent fields
+ while (!cursor.AtEnd())
+ {
+ var line = cursor.Peek();
+ if (line == null || line.Depth < firstField.FollowDepth)
+ break;
+
+ if (line.Depth == firstField.FollowDepth && !line.Content.StartsWith(Constants.LIST_ITEM_PREFIX))
+ {
+ var (k, v) = DecodeKeyValuePair(line, cursor, firstField.FollowDepth, options);
+ obj[k] = v;
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ return obj;
+ }
+
+ // #endregion
+ }
+}
diff --git a/src/ToonFormat/Internal/Decode/Parser.cs b/src/ToonFormat/Internal/Decode/Parser.cs
new file mode 100644
index 0000000..258662f
--- /dev/null
+++ b/src/ToonFormat/Internal/Decode/Parser.cs
@@ -0,0 +1,425 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json.Nodes;
+using ToonFormat.Internal.Shared;
+
+namespace ToonFormat.Internal.Decode
+{
+ ///
+ /// Information about an array header.
+ ///
+ internal class ArrayHeaderInfo
+ {
+ public string? Key { get; set; }
+ public int Length { get; set; }
+ public char Delimiter { get; set; }
+ public List? Fields { get; set; }
+ public bool HasLengthMarker { get; set; }
+ }
+
+ ///
+ /// Result of parsing an array header line.
+ ///
+ internal class ArrayHeaderParseResult
+ {
+ public ArrayHeaderInfo Header { get; set; } = null!;
+ public string? InlineValues { get; set; }
+ }
+
+ ///
+ /// Parsing utilities for TOON format tokens, headers, and values.
+ /// Aligned with TypeScript decode/parser.ts
+ ///
+ internal static class Parser
+ {
+ // #region Array header parsing
+
+ ///
+ /// Parses an array header line like "key[3]:" or "users[#2,]{name,age}:".
+ ///
+ public static ArrayHeaderParseResult? ParseArrayHeaderLine(string content, char defaultDelimiter)
+ {
+ var trimmed = content.TrimStart();
+
+ // Find the bracket segment, accounting for quoted keys that may contain brackets
+ int bracketStart = -1;
+
+ // For quoted keys, find bracket after closing quote (not inside the quoted string)
+ if (trimmed.StartsWith(Constants.DOUBLE_QUOTE))
+ {
+ var closingQuoteIndex = StringUtils.FindClosingQuote(trimmed, 0);
+ if (closingQuoteIndex == -1)
+ return null;
+
+ var afterQuote = trimmed.Substring(closingQuoteIndex + 1);
+ if (!afterQuote.StartsWith(Constants.OPEN_BRACKET.ToString()))
+ return null;
+
+ // Calculate position in original content and find bracket after the quoted key
+ var leadingWhitespace = content.Length - trimmed.Length;
+ var keyEndIndex = leadingWhitespace + closingQuoteIndex + 1;
+ bracketStart = content.IndexOf(Constants.OPEN_BRACKET, keyEndIndex);
+ }
+ else
+ {
+ // Unquoted key - find first bracket
+ bracketStart = content.IndexOf(Constants.OPEN_BRACKET);
+ }
+
+ if (bracketStart == -1)
+ return null;
+
+ var bracketEnd = content.IndexOf(Constants.CLOSE_BRACKET, bracketStart);
+ if (bracketEnd == -1)
+ return null;
+
+ // Find the colon that comes after all brackets and braces
+ int colonIndex = bracketEnd + 1;
+ int braceEnd = colonIndex;
+
+ // Check for fields segment (braces come after bracket)
+ var braceStart = content.IndexOf(Constants.OPEN_BRACE, bracketEnd);
+ if (braceStart != -1 && braceStart < content.IndexOf(Constants.COLON, bracketEnd))
+ {
+ var foundBraceEnd = content.IndexOf(Constants.CLOSE_BRACE, braceStart);
+ if (foundBraceEnd != -1)
+ {
+ braceEnd = foundBraceEnd + 1;
+ }
+ }
+
+ // Now find colon after brackets and braces
+ colonIndex = content.IndexOf(Constants.COLON, Math.Max(bracketEnd, braceEnd));
+ if (colonIndex == -1)
+ return null;
+
+ // Extract and parse the key (might be quoted)
+ string? key = null;
+ if (bracketStart > 0)
+ {
+ var rawKey = content.Substring(0, bracketStart).Trim();
+ key = rawKey.StartsWith(Constants.DOUBLE_QUOTE.ToString())
+ ? ParseStringLiteral(rawKey)
+ : rawKey;
+ }
+
+ var afterColon = content.Substring(colonIndex + 1).Trim();
+ var bracketContent = content.Substring(bracketStart + 1, bracketEnd - bracketStart - 1);
+
+ // Try to parse bracket segment
+ BracketSegmentResult parsedBracket;
+ try
+ {
+ parsedBracket = ParseBracketSegment(bracketContent, defaultDelimiter);
+ }
+ catch
+ {
+ return null;
+ }
+
+ // Check for fields segment
+ List? fields = null;
+ if (braceStart != -1 && braceStart < colonIndex)
+ {
+ var foundBraceEnd = content.IndexOf(Constants.CLOSE_BRACE, braceStart);
+ if (foundBraceEnd != -1 && foundBraceEnd < colonIndex)
+ {
+ var fieldsContent = content.Substring(braceStart + 1, foundBraceEnd - braceStart - 1);
+ fields = ParseDelimitedValues(fieldsContent, parsedBracket.Delimiter)
+ .Select(field => ParseStringLiteral(field.Trim()))
+ .ToList();
+ }
+ }
+
+ return new ArrayHeaderParseResult
+ {
+ Header = new ArrayHeaderInfo
+ {
+ Key = key,
+ Length = parsedBracket.Length,
+ Delimiter = parsedBracket.Delimiter,
+ Fields = fields,
+ HasLengthMarker = parsedBracket.HasLengthMarker
+ },
+ InlineValues = string.IsNullOrEmpty(afterColon) ? null : afterColon
+ };
+ }
+
+ private class BracketSegmentResult
+ {
+ public int Length { get; set; }
+ public char Delimiter { get; set; }
+ public bool HasLengthMarker { get; set; }
+ }
+
+ private static BracketSegmentResult ParseBracketSegment(string seg, char defaultDelimiter)
+ {
+ bool hasLengthMarker = false;
+ var content = seg;
+
+ // Check for length marker
+ if (content.StartsWith(Constants.HASH.ToString()))
+ {
+ hasLengthMarker = true;
+ content = content.Substring(1);
+ }
+
+ // Check for delimiter suffix
+ char delimiter = defaultDelimiter;
+ if (content.EndsWith(Constants.TAB.ToString()))
+ {
+ delimiter = Constants.TAB;
+ content = content.Substring(0, content.Length - 1);
+ }
+ else if (content.EndsWith(Constants.PIPE.ToString()))
+ {
+ delimiter = Constants.PIPE;
+ content = content.Substring(0, content.Length - 1);
+ }
+
+ if (!int.TryParse(content, out var length))
+ {
+ throw new FormatException($"Invalid array length: {seg}");
+ }
+
+ return new BracketSegmentResult
+ {
+ Length = length,
+ Delimiter = delimiter,
+ HasLengthMarker = hasLengthMarker
+ };
+ }
+
+ // #endregion
+
+ // #region Delimited value parsing
+
+ ///
+ /// Parses a delimiter-separated string into individual values, respecting quotes.
+ ///
+ public static List ParseDelimitedValues(string input, char delimiter)
+ {
+ var values = new List();
+ var current = string.Empty;
+ bool inQuotes = false;
+ int i = 0;
+
+ while (i < input.Length)
+ {
+ var ch = input[i];
+
+ if (ch == Constants.BACKSLASH && i + 1 < input.Length && inQuotes)
+ {
+ // Escape sequence in quoted string
+ current += ch.ToString() + input[i + 1];
+ i += 2;
+ continue;
+ }
+
+ if (ch == Constants.DOUBLE_QUOTE)
+ {
+ inQuotes = !inQuotes;
+ current += ch;
+ i++;
+ continue;
+ }
+
+ if (ch == delimiter && !inQuotes)
+ {
+ values.Add(current.Trim());
+ current = string.Empty;
+ i++;
+ continue;
+ }
+
+ current += ch;
+ i++;
+ }
+
+ // Add last value
+ if (!string.IsNullOrEmpty(current) || values.Count > 0)
+ {
+ values.Add(current.Trim());
+ }
+
+ return values;
+ }
+
+ ///
+ /// Maps an array of string tokens to JSON primitive values.
+ ///
+ public static List MapRowValuesToPrimitives(List values)
+ {
+ return values.Select(v => ParsePrimitiveToken(v)).ToList();
+ }
+
+ // #endregion
+
+ // #region Primitive and key parsing
+
+ ///
+ /// Parses a primitive token (null, boolean, number, or string).
+ ///
+ public static JsonNode? ParsePrimitiveToken(string token)
+ {
+ var trimmed = token.Trim();
+
+ // Empty token
+ if (string.IsNullOrEmpty(trimmed))
+ return JsonValue.Create(string.Empty);
+
+ // Quoted string (if starts with quote, it MUST be properly quoted)
+ if (trimmed.StartsWith(Constants.DOUBLE_QUOTE.ToString()))
+ {
+ return JsonValue.Create(ParseStringLiteral(trimmed));
+ }
+
+ // Boolean or null literals
+ if (LiteralUtils.IsBooleanOrNullLiteral(trimmed))
+ {
+ if (trimmed == Constants.TRUE_LITERAL)
+ return JsonValue.Create(true);
+ if (trimmed == Constants.FALSE_LITERAL)
+ return JsonValue.Create(false);
+ if (trimmed == Constants.NULL_LITERAL)
+ return null;
+ }
+
+ // Numeric literal
+ if (LiteralUtils.IsNumericLiteral(trimmed))
+ {
+ var parsedNumber = double.Parse(trimmed, CultureInfo.InvariantCulture);
+ // Normalize negative zero to positive zero
+ if (parsedNumber == 0.0 && double.IsNegativeInfinity(1.0 / parsedNumber))
+ return JsonValue.Create(0.0);
+ return JsonValue.Create(parsedNumber);
+ }
+
+ // Unquoted string
+ return JsonValue.Create(trimmed);
+ }
+
+ ///
+ /// Parses a string literal, handling quotes and escape sequences.
+ ///
+ public static string ParseStringLiteral(string token)
+ {
+ var trimmedToken = token.Trim();
+
+ if (trimmedToken.StartsWith(Constants.DOUBLE_QUOTE.ToString()))
+ {
+ // Find the closing quote, accounting for escaped quotes
+ var closingQuoteIndex = StringUtils.FindClosingQuote(trimmedToken, 0);
+
+ if (closingQuoteIndex == -1)
+ {
+ throw ToonFormatException.Syntax("Unterminated string: missing closing quote");
+ }
+
+ if (closingQuoteIndex != trimmedToken.Length - 1)
+ {
+ throw ToonFormatException.Syntax("Unexpected characters after closing quote");
+ }
+
+ var content = trimmedToken.Substring(1, closingQuoteIndex - 1);
+ return StringUtils.UnescapeString(content);
+ }
+
+ return trimmedToken;
+ }
+
+ public class KeyParseResult
+ {
+ public string Key { get; set; } = string.Empty;
+ public int End { get; set; }
+ }
+
+ public static KeyParseResult ParseUnquotedKey(string content, int start)
+ {
+ int end = start;
+ while (end < content.Length && content[end] != Constants.COLON)
+ {
+ end++;
+ }
+
+ // Validate that a colon was found
+ if (end >= content.Length || content[end] != Constants.COLON)
+ {
+ throw ToonFormatException.Syntax("Missing colon after key");
+ }
+
+ var key = content.Substring(start, end - start).Trim();
+
+ // Skip the colon
+ end++;
+
+ return new KeyParseResult { Key = key, End = end };
+ }
+
+ public static KeyParseResult ParseQuotedKey(string content, int start)
+ {
+ // Find the closing quote, accounting for escaped quotes
+ var closingQuoteIndex = StringUtils.FindClosingQuote(content, start);
+
+ if (closingQuoteIndex == -1)
+ {
+ throw ToonFormatException.Syntax("Unterminated quoted key");
+ }
+
+ // Extract and unescape the key content
+ var keyContent = content.Substring(start + 1, closingQuoteIndex - start - 1);
+ var key = StringUtils.UnescapeString(keyContent);
+ int end = closingQuoteIndex + 1;
+
+ // Validate and skip colon after quoted key
+ if (end >= content.Length || content[end] != Constants.COLON)
+ {
+ throw ToonFormatException.Syntax("Missing colon after key");
+ }
+ end++;
+
+ return new KeyParseResult { Key = key, End = end };
+ }
+
+ ///
+ /// Parses a key token (quoted or unquoted) and returns the key and position after colon.
+ ///
+ public static KeyParseResult ParseKeyToken(string content, int start)
+ {
+ if (content[start] == Constants.DOUBLE_QUOTE)
+ {
+ return ParseQuotedKey(content, start);
+ }
+ else
+ {
+ return ParseUnquotedKey(content, start);
+ }
+ }
+
+ // #endregion
+
+ // #region Array content detection helpers
+
+ ///
+ /// Checks if content after hyphen starts with an array header.
+ ///
+ public static bool IsArrayHeaderAfterHyphen(string content)
+ {
+ return content.Trim().StartsWith(Constants.OPEN_BRACKET.ToString())
+ && StringUtils.FindUnquotedChar(content, Constants.COLON) != -1;
+ }
+
+ ///
+ /// Checks if content after hyphen contains a key-value pair (has a colon).
+ ///
+ public static bool IsObjectFirstFieldAfterHyphen(string content)
+ {
+ return StringUtils.FindUnquotedChar(content, Constants.COLON) != -1;
+ }
+
+ // #endregion
+ }
+}
diff --git a/src/ToonFormat/Internal/Decode/Scanner.cs b/src/ToonFormat/Internal/Decode/Scanner.cs
new file mode 100644
index 0000000..096e1fd
--- /dev/null
+++ b/src/ToonFormat/Internal/Decode/Scanner.cs
@@ -0,0 +1,190 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ToonFormat.Internal.Decode
+{
+ ///
+ /// Represents a parsed line with its raw content, indentation, depth, and line number.
+ ///
+ internal class ParsedLine
+ {
+ public string Raw { get; set; } = string.Empty;
+ public int Indent { get; set; }
+ public string Content { get; set; } = string.Empty;
+ public int Depth { get; set; }
+ public int LineNumber { get; set; }
+ }
+
+ ///
+ /// Information about a blank line in the source.
+ ///
+ internal class BlankLineInfo
+ {
+ public int LineNumber { get; set; }
+ public int Indent { get; set; }
+ public int Depth { get; set; }
+ }
+
+ ///
+ /// Result of scanning source text into parsed lines.
+ ///
+ internal class ScanResult
+ {
+ public List Lines { get; set; } = new();
+ public List BlankLines { get; set; } = new();
+ }
+
+ ///
+ /// Cursor for navigating through parsed lines during decoding.
+ /// Aligned with TypeScript decode/scanner.ts LineCursor
+ ///
+ internal class LineCursor
+ {
+ private readonly List _lines;
+ private readonly List _blankLines;
+ private int _index;
+
+ public LineCursor(List lines, List blankLines)
+ {
+ _lines = lines;
+ _blankLines = blankLines;
+ _index = 0;
+ }
+
+ public List GetBlankLines() => _blankLines;
+
+ public ParsedLine? Peek()
+ {
+ return _index < _lines.Count ? _lines[_index] : null;
+ }
+
+ public ParsedLine? Next()
+ {
+ return _index < _lines.Count ? _lines[_index++] : null;
+ }
+
+ public ParsedLine? Current()
+ {
+ return _index > 0 ? _lines[_index - 1] : null;
+ }
+
+ public void Advance()
+ {
+ _index++;
+ }
+
+ public bool AtEnd()
+ {
+ return _index >= _lines.Count;
+ }
+
+ public int Length => _lines.Count;
+
+ public ParsedLine? PeekAtDepth(int targetDepth)
+ {
+ var line = Peek();
+ if (line == null || line.Depth < targetDepth)
+ return null;
+ if (line.Depth == targetDepth)
+ return line;
+ return null;
+ }
+
+ public bool HasMoreAtDepth(int targetDepth)
+ {
+ return PeekAtDepth(targetDepth) != null;
+ }
+ }
+
+ ///
+ /// Scanner utilities for parsing source text into structured lines.
+ /// Aligned with TypeScript decode/scanner.ts
+ ///
+ internal static class Scanner
+ {
+ ///
+ /// Parses source text into a list of structured lines with depth information.
+ ///
+ public static ScanResult ToParsedLines(string source, int indentSize, bool strict)
+ {
+ if (string.IsNullOrWhiteSpace(source))
+ {
+ return new ScanResult();
+ }
+
+ var lines = source.Split('\n');
+ var parsed = new List();
+ var blankLines = new List();
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ var raw = lines[i];
+ var lineNumber = i + 1;
+ int indent = 0;
+
+ while (indent < raw.Length && raw[indent] == Constants.SPACE)
+ {
+ indent++;
+ }
+
+ var content = raw.Substring(indent);
+
+ // Track blank lines
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ var depth = ComputeDepthFromIndent(indent, indentSize);
+ blankLines.Add(new BlankLineInfo
+ {
+ LineNumber = lineNumber,
+ Indent = indent,
+ Depth = depth
+ });
+ continue;
+ }
+
+ var lineDepth = ComputeDepthFromIndent(indent, indentSize);
+
+ // Strict mode validation
+ if (strict)
+ {
+ // Find the full leading whitespace region (spaces and tabs)
+ int wsEnd = 0;
+ while (wsEnd < raw.Length && (raw[wsEnd] == Constants.SPACE || raw[wsEnd] == Constants.TAB))
+ {
+ wsEnd++;
+ }
+
+ // Check for tabs in leading whitespace (before actual content)
+ if (raw.Substring(0, wsEnd).Contains(Constants.TAB))
+ {
+ throw ToonFormatException.Syntax($"Line {lineNumber}: Tabs are not allowed in indentation in strict mode");
+ }
+
+ // Check for exact multiples of indentSize
+ if (indent > 0 && indent % indentSize != 0)
+ {
+ throw ToonFormatException.Syntax($"Line {lineNumber}: Indentation must be exact multiple of {indentSize}, but found {indent} spaces");
+ }
+ }
+
+ parsed.Add(new ParsedLine
+ {
+ Raw = raw,
+ Indent = indent,
+ Content = content,
+ Depth = lineDepth,
+ LineNumber = lineNumber
+ });
+ }
+
+ return new ScanResult { Lines = parsed, BlankLines = blankLines };
+ }
+
+ private static int ComputeDepthFromIndent(int indentSpaces, int indentSize)
+ {
+ return indentSpaces / indentSize;
+ }
+ }
+}
diff --git a/src/ToonFormat/Internal/Decode/Validation.cs b/src/ToonFormat/Internal/Decode/Validation.cs
new file mode 100644
index 0000000..2b6efbd
--- /dev/null
+++ b/src/ToonFormat/Internal/Decode/Validation.cs
@@ -0,0 +1,150 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ToonFormat.Internal.Decode
+{
+ ///
+ /// Options for decoding TOON format.
+ ///
+ internal class ResolvedDecodeOptions
+ {
+ public int Indent { get; set; } = 2;
+ public bool Strict { get; set; } = false;
+ }
+
+ ///
+ /// Validation utilities for TOON decoding.
+ /// Aligned with TypeScript decode/validation.ts
+ ///
+ internal static class Validation
+ {
+ ///
+ /// Asserts that the actual count matches the expected count in strict mode.
+ ///
+ /// The actual count
+ /// The expected count
+ /// The type of items being counted (e.g., "list array items", "tabular rows")
+ /// Decode options
+ /// Thrown if counts don't match in strict mode
+ public static void AssertExpectedCount(
+ int actual,
+ int expected,
+ string itemType,
+ ResolvedDecodeOptions options)
+ {
+ if (options.Strict && actual != expected)
+ {
+ throw ToonFormatException.Range($"Expected {expected} {itemType}, but got {actual}");
+ }
+ }
+
+ ///
+ /// Validates that there are no extra list items beyond the expected count.
+ ///
+ /// The line cursor
+ /// The expected depth of items
+ /// The expected number of items
+ /// Thrown if extra items are found
+ public static void ValidateNoExtraListItems(
+ LineCursor cursor,
+ int itemDepth,
+ int expectedCount)
+ {
+ if (cursor.AtEnd())
+ return;
+
+ var nextLine = cursor.Peek();
+ if (nextLine != null && nextLine.Depth == itemDepth && nextLine.Content.StartsWith(Constants.LIST_ITEM_PREFIX))
+ {
+ throw ToonFormatException.Range($"Expected {expectedCount} list array items, but found more");
+ }
+ }
+
+ ///
+ /// Validates that there are no extra tabular rows beyond the expected count.
+ ///
+ /// The line cursor
+ /// The expected depth of rows
+ /// The array header info containing length and delimiter
+ /// Thrown if extra rows are found
+ public static void ValidateNoExtraTabularRows(
+ LineCursor cursor,
+ int rowDepth,
+ ArrayHeaderInfo header)
+ {
+ if (cursor.AtEnd())
+ return;
+
+ var nextLine = cursor.Peek();
+ if (nextLine != null
+ && nextLine.Depth == rowDepth
+ && !nextLine.Content.StartsWith(Constants.LIST_ITEM_PREFIX)
+ && IsDataRow(nextLine.Content, header.Delimiter))
+ {
+ throw ToonFormatException.Range($"Expected {header.Length} tabular rows, but found more");
+ }
+ }
+
+ ///
+ /// Validates that there are no blank lines within a specific line range and depth.
+ ///
+ ///
+ /// In strict mode, blank lines inside arrays/tabular rows are not allowed.
+ ///
+ /// The starting line number (inclusive)
+ /// The ending line number (inclusive)
+ /// Array of blank line information
+ /// Whether strict mode is enabled
+ /// Description of the context (e.g., "list array", "tabular array")
+ /// Thrown if blank lines are found in strict mode
+ public static void ValidateNoBlankLinesInRange(
+ int startLine,
+ int endLine,
+ List blankLines,
+ bool strict,
+ string context)
+ {
+ if (!strict)
+ return;
+
+ // Find blank lines within the range
+ // Note: We don't filter by depth because ANY blank line between array items is an error,
+ // regardless of its indentation level
+ var blanksInRange = blankLines.Where(
+ blank => blank.LineNumber > startLine && blank.LineNumber < endLine
+ ).ToList();
+
+ if (blanksInRange.Count > 0)
+ {
+ throw ToonFormatException.Syntax(
+ $"Line {blanksInRange[0].LineNumber}: Blank lines inside {context} are not allowed in strict mode"
+ );
+ }
+ }
+
+ ///
+ /// Checks if a line represents a data row (as opposed to a key-value pair) in a tabular array.
+ ///
+ /// The line content
+ /// The delimiter used in the table
+ /// true if the line is a data row, false if it's a key-value pair
+ private static bool IsDataRow(string content, char delimiter)
+ {
+ var colonPos = content.IndexOf(Constants.COLON);
+ var delimiterPos = content.IndexOf(delimiter);
+
+ // No colon = definitely a data row
+ if (colonPos == -1)
+ return true;
+
+ // Has delimiter and it comes before colon = data row
+ if (delimiterPos != -1 && delimiterPos < colonPos)
+ return true;
+
+ // Colon before delimiter or no delimiter = key-value pair
+ return false;
+ }
+ }
+}
diff --git a/src/ToonFormat/Internal/Encode/Encoders.cs b/src/ToonFormat/Internal/Encode/Encoders.cs
new file mode 100644
index 0000000..8f05471
--- /dev/null
+++ b/src/ToonFormat/Internal/Encode/Encoders.cs
@@ -0,0 +1,445 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+
+namespace ToonFormat.Internal.Encode
+{
+ ///
+ /// Options for encoding TOON format, aligned with TypeScript ResolvedEncodeOptions.
+ ///
+ internal class ResolvedEncodeOptions
+ {
+ public int Indent { get; set; } = 2;
+ public char Delimiter { get; set; } = Constants.COMMA;
+ public bool LengthMarker { get; set; } = false;
+ }
+
+ ///
+ /// Main encoding functions for converting normalized JsonNode values to TOON format.
+ /// Aligned with TypeScript encode/encoders.ts
+ ///
+ internal static class Encoders
+ {
+ // #region Encode normalized JsonValue
+
+ ///
+ /// Encodes a normalized JsonNode value to TOON format string.
+ ///
+ public static string EncodeValue(JsonNode? value, ResolvedEncodeOptions options)
+ {
+ if (Normalize.IsJsonPrimitive(value))
+ {
+ return Primitives.EncodePrimitive(value, options.Delimiter);
+ }
+
+ var writer = new LineWriter(options.Indent);
+
+ if (Normalize.IsJsonArray(value))
+ {
+ EncodeArray(null, (JsonArray)value!, writer, 0, options);
+ }
+ else if (Normalize.IsJsonObject(value))
+ {
+ EncodeObject((JsonObject)value!, writer, 0, options);
+ }
+
+ return writer.ToString();
+ }
+
+ // #endregion
+
+ // #region Object encoding
+
+ ///
+ /// Encodes a JsonObject as key-value pairs.
+ ///
+ public static void EncodeObject(JsonObject value, LineWriter writer, int depth, ResolvedEncodeOptions options)
+ {
+ foreach (var kvp in value)
+ {
+ EncodeKeyValuePair(kvp.Key, kvp.Value, writer, depth, options);
+ }
+ }
+
+ ///
+ /// Encodes a single key-value pair.
+ ///
+ public static void EncodeKeyValuePair(string key, JsonNode? value, LineWriter writer, int depth, ResolvedEncodeOptions options)
+ {
+ var encodedKey = Primitives.EncodeKey(key);
+
+ if (Normalize.IsJsonPrimitive(value))
+ {
+ writer.Push(depth, $"{encodedKey}{Constants.COLON} {Primitives.EncodePrimitive(value, options.Delimiter)}");
+ }
+ else if (Normalize.IsJsonArray(value))
+ {
+ EncodeArray(key, (JsonArray)value!, writer, depth, options);
+ }
+ else if (Normalize.IsJsonObject(value))
+ {
+ var obj = (JsonObject)value!;
+ if (obj.Count == 0)
+ {
+ // Empty object
+ writer.Push(depth, $"{encodedKey}{Constants.COLON}");
+ }
+ else
+ {
+ writer.Push(depth, $"{encodedKey}{Constants.COLON}");
+ EncodeObject(obj, writer, depth + 1, options);
+ }
+ }
+ }
+
+ // #endregion
+
+ // #region Array encoding
+
+ ///
+ /// Encodes a JsonArray with appropriate formatting (inline, tabular, or expanded).
+ ///
+ public static void EncodeArray(
+ string? key,
+ JsonArray value,
+ LineWriter writer,
+ int depth,
+ ResolvedEncodeOptions options)
+ {
+ if (value.Count == 0)
+ {
+ var header = Primitives.FormatHeader(0, key, null, options.Delimiter, options.LengthMarker);
+ writer.Push(depth, header);
+ return;
+ }
+
+ // Primitive array
+ if (Normalize.IsArrayOfPrimitives(value))
+ {
+ var formatted = EncodeInlineArrayLine(value, options.Delimiter, key, options.LengthMarker);
+ writer.Push(depth, formatted);
+ return;
+ }
+
+ // Array of arrays (all primitives)
+ if (Normalize.IsArrayOfArrays(value))
+ {
+ var allPrimitiveArrays = value.All(item =>
+ item is JsonArray arr && Normalize.IsArrayOfPrimitives(arr));
+
+ if (allPrimitiveArrays)
+ {
+ EncodeArrayOfArraysAsListItems(key, value.Cast().ToList(), writer, depth, options);
+ return;
+ }
+ }
+
+ // Array of objects
+ if (Normalize.IsArrayOfObjects(value))
+ {
+ var objects = value.Cast().ToList();
+ var header = ExtractTabularHeader(objects);
+ if (header != null)
+ {
+ EncodeArrayOfObjectsAsTabular(key, objects, header, writer, depth, options);
+ }
+ else
+ {
+ EncodeMixedArrayAsListItems(key, value, writer, depth, options);
+ }
+ return;
+ }
+
+ // Mixed array: fallback to expanded format
+ EncodeMixedArrayAsListItems(key, value, writer, depth, options);
+ }
+
+ // #endregion
+
+ // #region Array of arrays (expanded format)
+
+ ///
+ /// Encodes an array of arrays as list items.
+ ///
+ public static void EncodeArrayOfArraysAsListItems(
+ string? prefix,
+ IReadOnlyList values,
+ LineWriter writer,
+ int depth,
+ ResolvedEncodeOptions options)
+ {
+ var header = Primitives.FormatHeader(values.Count, prefix, null, options.Delimiter, options.LengthMarker);
+ writer.Push(depth, header);
+
+ foreach (var arr in values)
+ {
+ if (Normalize.IsArrayOfPrimitives(arr))
+ {
+ var inline = EncodeInlineArrayLine(arr, options.Delimiter, null, options.LengthMarker);
+ writer.PushListItem(depth + 1, inline);
+ }
+ }
+ }
+
+ ///
+ /// Encodes an array as a single inline line with header.
+ ///
+ public static string EncodeInlineArrayLine(
+ JsonArray values,
+ char delimiter,
+ string? prefix = null,
+ bool lengthMarker = false)
+ {
+ var header = Primitives.FormatHeader(values.Count, prefix, null, delimiter, lengthMarker);
+
+ if (values.Count == 0)
+ {
+ return header;
+ }
+
+ var joinedValue = Primitives.EncodeAndJoinPrimitives(values, delimiter);
+ return $"{header} {joinedValue}";
+ }
+
+ // #endregion
+
+ // #region Array of objects (tabular format)
+
+ ///
+ /// Encodes an array of objects in tabular format.
+ ///
+ public static void EncodeArrayOfObjectsAsTabular(
+ string? prefix,
+ IReadOnlyList rows,
+ IReadOnlyList header,
+ LineWriter writer,
+ int depth,
+ ResolvedEncodeOptions options)
+ {
+ var formattedHeader = Primitives.FormatHeader(rows.Count, prefix, header, options.Delimiter, options.LengthMarker);
+ writer.Push(depth, formattedHeader);
+
+ WriteTabularRows(rows, header, writer, depth + 1, options);
+ }
+
+ ///
+ /// Extracts a uniform header from an array of objects if all objects have the same keys.
+ /// Returns null if the array cannot be represented in tabular format.
+ ///
+ public static IReadOnlyList? ExtractTabularHeader(IReadOnlyList rows)
+ {
+ if (rows.Count == 0)
+ return null;
+
+ var firstRow = rows[0];
+ var firstKeys = firstRow.Select(kvp => kvp.Key).ToList();
+
+ if (firstKeys.Count == 0)
+ return null;
+
+ if (IsTabularArray(rows, firstKeys))
+ {
+ return firstKeys;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Checks if an array of objects can be represented in tabular format.
+ /// All objects must have the same keys and all values must be primitives.
+ ///
+ public static bool IsTabularArray(
+ IReadOnlyList rows,
+ IReadOnlyList header)
+ {
+ foreach (var row in rows)
+ {
+ var keys = row.Select(kvp => kvp.Key).ToList();
+
+ // All objects must have the same keys (but order can differ)
+ if (keys.Count != header.Count)
+ return false;
+
+ // Check that all header keys exist in the row and all values are primitives
+ foreach (var key in header)
+ {
+ if (!row.ContainsKey(key))
+ return false;
+
+ if (!Normalize.IsJsonPrimitive(row[key]))
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Writes tabular rows to the writer.
+ ///
+ private static void WriteTabularRows(
+ IReadOnlyList rows,
+ IReadOnlyList header,
+ LineWriter writer,
+ int depth,
+ ResolvedEncodeOptions options)
+ {
+ foreach (var row in rows)
+ {
+ var values = header.Select(key => row[key]).ToList();
+ var joinedValue = Primitives.EncodeAndJoinPrimitives(values, options.Delimiter);
+ writer.Push(depth, joinedValue);
+ }
+ }
+
+ // #endregion
+
+ // #region Array of objects (expanded format)
+
+ ///
+ /// Encodes a mixed array as list items (expanded format).
+ ///
+ public static void EncodeMixedArrayAsListItems(
+ string? prefix,
+ JsonArray items,
+ LineWriter writer,
+ int depth,
+ ResolvedEncodeOptions options)
+ {
+ var header = Primitives.FormatHeader(items.Count, prefix, null, options.Delimiter, options.LengthMarker);
+ writer.Push(depth, header);
+
+ foreach (var item in items)
+ {
+ EncodeListItemValue(item, writer, depth + 1, options);
+ }
+ }
+
+ ///
+ /// Encodes an object as a list item with special formatting for the first property.
+ ///
+ public static void EncodeObjectAsListItem(JsonObject obj, LineWriter writer, int depth, ResolvedEncodeOptions options)
+ {
+ var keys = obj.Select(kvp => kvp.Key).ToList();
+
+ if (keys.Count == 0)
+ {
+ writer.Push(depth, Constants.LIST_ITEM_MARKER.ToString());
+ return;
+ }
+
+ // First key-value on the same line as "- "
+ var firstKey = keys[0];
+ var encodedKey = Primitives.EncodeKey(firstKey);
+ var firstValue = obj[firstKey];
+
+ if (Normalize.IsJsonPrimitive(firstValue))
+ {
+ writer.PushListItem(depth, $"{encodedKey}{Constants.COLON} {Primitives.EncodePrimitive(firstValue, options.Delimiter)}");
+ }
+ else if (Normalize.IsJsonArray(firstValue))
+ {
+ var arr = (JsonArray)firstValue!;
+
+ if (Normalize.IsArrayOfPrimitives(arr))
+ {
+ // Inline format for primitive arrays
+ var formatted = EncodeInlineArrayLine(arr, options.Delimiter, firstKey, options.LengthMarker);
+ writer.PushListItem(depth, formatted);
+ }
+ else if (Normalize.IsArrayOfObjects(arr))
+ {
+ // Check if array of objects can use tabular format
+ var objects = arr.Cast().ToList();
+ var header = ExtractTabularHeader(objects);
+
+ if (header != null)
+ {
+ // Tabular format for uniform arrays of objects
+ var formattedHeader = Primitives.FormatHeader(arr.Count, firstKey, header, options.Delimiter, options.LengthMarker);
+ writer.PushListItem(depth, formattedHeader);
+ WriteTabularRows(objects, header, writer, depth + 1, options);
+ }
+ else
+ {
+ // Fall back to list format for non-uniform arrays of objects
+ writer.PushListItem(depth, $"{encodedKey}{Constants.OPEN_BRACKET}{arr.Count}{Constants.CLOSE_BRACKET}{Constants.COLON}");
+ foreach (var item in arr)
+ {
+ if (item is JsonObject itemObj)
+ {
+ EncodeObjectAsListItem(itemObj, writer, depth + 1, options);
+ }
+ }
+ }
+ }
+ else
+ {
+ // Complex arrays on separate lines (array of arrays, etc.)
+ writer.PushListItem(depth, $"{encodedKey}{Constants.OPEN_BRACKET}{arr.Count}{Constants.CLOSE_BRACKET}{Constants.COLON}");
+
+ // Encode array contents at depth + 1
+ foreach (var item in arr)
+ {
+ EncodeListItemValue(item, writer, depth + 1, options);
+ }
+ }
+ }
+ else if (Normalize.IsJsonObject(firstValue))
+ {
+ var nestedObj = (JsonObject)firstValue!;
+
+ if (nestedObj.Count == 0)
+ {
+ writer.PushListItem(depth, $"{encodedKey}{Constants.COLON}");
+ }
+ else
+ {
+ writer.PushListItem(depth, $"{encodedKey}{Constants.COLON}");
+ EncodeObject(nestedObj, writer, depth + 2, options);
+ }
+ }
+
+ // Remaining keys on indented lines
+ for (int i = 1; i < keys.Count; i++)
+ {
+ var key = keys[i];
+ EncodeKeyValuePair(key, obj[key], writer, depth + 1, options);
+ }
+ }
+
+ // #endregion
+
+ // #region List item encoding helpers
+
+ ///
+ /// Encodes a value as a list item.
+ ///
+ private static void EncodeListItemValue(
+ JsonNode? value,
+ LineWriter writer,
+ int depth,
+ ResolvedEncodeOptions options)
+ {
+ if (Normalize.IsJsonPrimitive(value))
+ {
+ writer.PushListItem(depth, Primitives.EncodePrimitive(value, options.Delimiter));
+ }
+ else if (Normalize.IsJsonArray(value) && Normalize.IsArrayOfPrimitives((JsonArray)value!))
+ {
+ var arr = (JsonArray)value!;
+ var inline = EncodeInlineArrayLine(arr, options.Delimiter, null, options.LengthMarker);
+ writer.PushListItem(depth, inline);
+ }
+ else if (Normalize.IsJsonObject(value))
+ {
+ EncodeObjectAsListItem((JsonObject)value!, writer, depth, options);
+ }
+ }
+
+ // #endregion
+ }
+}
diff --git a/src/ToonFormat/Internal/Encode/LineWriter.cs b/src/ToonFormat/Internal/Encode/LineWriter.cs
new file mode 100644
index 0000000..fb3409f
--- /dev/null
+++ b/src/ToonFormat/Internal/Encode/LineWriter.cs
@@ -0,0 +1,70 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Text;
+
+namespace ToonFormat.Internal.Encode
+{
+ ///
+ /// Helper class for building indented lines of TOON output.
+ /// Aligned with TypeScript encode/writer.ts
+ ///
+ internal class LineWriter
+ {
+ private readonly List _lines = new();
+ private readonly string _indentationString;
+
+ ///
+ /// Creates a new LineWriter with the specified indentation size.
+ ///
+ /// Number of spaces per indentation level.
+ public LineWriter(int indentSize)
+ {
+ _indentationString = new string(' ', indentSize);
+ }
+
+ ///
+ /// Pushes a new line with the specified depth and content.
+ ///
+ /// Indentation depth level.
+ /// The content of the line.
+ public void Push(int depth, string content)
+ {
+ var indent = RepeatString(_indentationString, depth);
+ _lines.Add(indent + content);
+ }
+
+ ///
+ /// Pushes a list item (prefixed with "- ") at the specified depth.
+ ///
+ /// Indentation depth level.
+ /// The content after the list item marker.
+ public void PushListItem(int depth, string content)
+ {
+ Push(depth, Constants.LIST_ITEM_PREFIX + content);
+ }
+
+ ///
+ /// Returns the complete output as a single string with newlines.
+ ///
+ public override string ToString()
+ {
+ return string.Join("\n", _lines);
+ }
+
+ ///
+ /// Helper method to repeat a string n times.
+ ///
+ private static string RepeatString(string str, int count)
+ {
+ if (count <= 0)
+ return string.Empty;
+
+ var sb = new StringBuilder(str.Length * count);
+ for (int i = 0; i < count; i++)
+ {
+ sb.Append(str);
+ }
+ return sb.ToString();
+ }
+ }
+}
diff --git a/src/ToonFormat/Internal/Encode/Normalize.cs b/src/ToonFormat/Internal/Encode/Normalize.cs
new file mode 100644
index 0000000..72a9ee8
--- /dev/null
+++ b/src/ToonFormat/Internal/Encode/Normalize.cs
@@ -0,0 +1,308 @@
+#nullable enable
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ToonFormat.Internal.Encode
+{
+ ///
+ /// Normalization utilities for converting arbitrary .NET objects to JsonNode representations
+ /// and type guards for JSON value classification.
+ /// Aligned with TypeScript encode/normalize.ts
+ ///
+ internal static class Normalize
+ {
+ // #region Normalization (object → JsonNode)
+
+ ///
+ /// Normalizes an arbitrary .NET value to a JsonNode representation.
+ /// Handles primitives, collections, dates, and custom objects.
+ ///
+ public static JsonNode? NormalizeValue(object? value)
+ {
+ // null
+ if (value == null)
+ return null;
+
+ // Primitives: string, boolean
+ if (value is string str)
+ return JsonValue.Create(str);
+
+ if (value is bool b)
+ return JsonValue.Create(b);
+
+ // Numbers: canonicalize -0 to 0, handle NaN and Infinity
+ if (value is double d)
+ {
+ // Detect -0 using Object.Equals or bitwise comparison
+ if (d == 0.0 && double.IsNegative(d))
+ return JsonValue.Create(0.0);
+ if (!double.IsFinite(d))
+ return null;
+ return JsonValue.Create(d);
+ }
+
+ if (value is float f)
+ {
+ if (f == 0.0f && float.IsNegative(f))
+ return JsonValue.Create(0.0f);
+ if (!float.IsFinite(f))
+ return null;
+ return JsonValue.Create(f);
+ }
+
+ // Other numeric types
+ if (value is int i) return JsonValue.Create(i);
+ if (value is long l) return JsonValue.Create(l);
+ if (value is decimal dec) return JsonValue.Create(dec);
+ if (value is byte by) return JsonValue.Create(by);
+ if (value is sbyte sb) return JsonValue.Create(sb);
+ if (value is short sh) return JsonValue.Create(sh);
+ if (value is ushort us) return JsonValue.Create(us);
+ if (value is uint ui) return JsonValue.Create(ui);
+ if (value is ulong ul) return JsonValue.Create(ul);
+
+ // DateTime → ISO string
+ if (value is DateTime dt)
+ return JsonValue.Create(dt.ToString("O")); // ISO 8601 format
+
+ if (value is DateTimeOffset dto)
+ return JsonValue.Create(dto.ToString("O"));
+
+ // Array/List → JsonArray
+ if (value is IEnumerable enumerable && value is not string)
+ {
+ var jsonArray = new JsonArray();
+ foreach (var item in enumerable)
+ {
+ jsonArray.Add(NormalizeValue(item));
+ }
+ return jsonArray;
+ }
+
+ // Dictionary/Object → JsonObject
+ if (value is IDictionary dict)
+ {
+ var jsonObject = new JsonObject();
+ foreach (DictionaryEntry entry in dict)
+ {
+ var key = entry.Key?.ToString() ?? string.Empty;
+ jsonObject[key] = NormalizeValue(entry.Value);
+ }
+ return jsonObject;
+ }
+
+ // Plain object → JsonObject via reflection
+ if (IsPlainObject(value))
+ {
+ var jsonObject = new JsonObject();
+ var type = value.GetType();
+ var properties = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+
+ foreach (var prop in properties)
+ {
+ if (prop.CanRead)
+ {
+ var propValue = prop.GetValue(value);
+ jsonObject[prop.Name] = NormalizeValue(propValue);
+ }
+ }
+
+ return jsonObject;
+ }
+
+ // Fallback: unsupported types → null
+ return null;
+ }
+
+ ///
+ /// Normalizes a value of generic type to a JsonNode representation.
+ /// This overload aims to avoid an initial boxing for common value types.
+ ///
+ public static JsonNode? NormalizeValue(T value)
+ {
+ // null
+ if (value is null)
+ return null;
+
+ // Fast-path primitives without boxing
+ switch (value)
+ {
+ case string s:
+ return JsonValue.Create(s);
+ case bool b:
+ return JsonValue.Create(b);
+ case int i:
+ return JsonValue.Create(i);
+ case long l:
+ return JsonValue.Create(l);
+ case double d:
+ if (d == 0.0 && double.IsNegative(d)) return JsonValue.Create(0.0);
+ if (!double.IsFinite(d)) return null;
+ return JsonValue.Create(d);
+ case float f:
+ if (f == 0.0f && float.IsNegative(f)) return JsonValue.Create(0.0f);
+ if (!float.IsFinite(f)) return null;
+ return JsonValue.Create(f);
+ case decimal dec:
+ return JsonValue.Create(dec);
+ case byte by:
+ return JsonValue.Create(by);
+ case sbyte sb:
+ return JsonValue.Create(sb);
+ case short sh:
+ return JsonValue.Create(sh);
+ case ushort us:
+ return JsonValue.Create(us);
+ case uint ui:
+ return JsonValue.Create(ui);
+ case ulong ul:
+ return JsonValue.Create(ul);
+ case DateTime dt:
+ return JsonValue.Create(dt.ToString("O"));
+ case DateTimeOffset dto:
+ return JsonValue.Create(dto.ToString("O"));
+ }
+
+ // Collections / dictionaries (pattern checks avoid boxing for value-type T that implement the interfaces)
+ if (value is IEnumerable enumerable && value is not string)
+ {
+ var jsonArray = new JsonArray();
+ foreach (var item in enumerable)
+ {
+ jsonArray.Add(NormalizeValue(item));
+ }
+ return jsonArray;
+ }
+
+ if (value is IDictionary dict)
+ {
+ var jsonObject = new JsonObject();
+ foreach (DictionaryEntry entry in dict)
+ {
+ var key = entry.Key?.ToString() ?? string.Empty;
+ jsonObject[key] = NormalizeValue(entry.Value);
+ }
+ return jsonObject;
+ }
+
+ // Plain object via reflection (boxing for value types here is acceptable and rare)
+ if (IsPlainObject(value!))
+ {
+ var jsonObject = new JsonObject();
+ var type = value!.GetType();
+ var properties = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+
+ foreach (var prop in properties)
+ {
+ if (prop.CanRead)
+ {
+ var propValue = prop.GetValue(value);
+ jsonObject[prop.Name] = NormalizeValue(propValue);
+ }
+ }
+
+ return jsonObject;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Determines if a value is a plain object (not a primitive, collection, or special type).
+ ///
+ private static bool IsPlainObject(object value)
+ {
+ if (value == null)
+ return false;
+
+ var type = value.GetType();
+
+ // Exclude primitives, strings, and special types
+ if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime) || type == typeof(DateTimeOffset))
+ return false;
+
+ // Exclude collections
+ if (typeof(IEnumerable).IsAssignableFrom(type))
+ return false;
+
+ // Accept class or struct types
+ return type.IsClass || type.IsValueType;
+ }
+
+ // #endregion
+
+ // #region Type guards
+
+ ///
+ /// Checks if a JsonNode is a primitive value (null, string, number, or boolean).
+ ///
+ public static bool IsJsonPrimitive(JsonNode? value)
+ {
+ if (value == null)
+ return true;
+
+ if (value is JsonValue jsonValue)
+ {
+ // Check if it's a primitive type
+ return jsonValue.TryGetValue(out _)
+ || jsonValue.TryGetValue(out _)
+ || jsonValue.TryGetValue(out _)
+ || jsonValue.TryGetValue(out _)
+ || jsonValue.TryGetValue(out _)
+ || jsonValue.TryGetValue(out _);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if a JsonNode is a JsonArray.
+ ///
+ public static bool IsJsonArray(JsonNode? value)
+ {
+ return value is JsonArray;
+ }
+
+ ///
+ /// Checks if a JsonNode is a JsonObject.
+ ///
+ public static bool IsJsonObject(JsonNode? value)
+ {
+ return value is JsonObject;
+ }
+
+ // #endregion
+
+ // #region Array type detection
+
+ ///
+ /// Checks if a JsonArray contains only primitive values.
+ ///
+ public static bool IsArrayOfPrimitives(JsonArray array)
+ {
+ return array.All(item => IsJsonPrimitive(item));
+ }
+
+ ///
+ /// Checks if a JsonArray contains only arrays.
+ ///
+ public static bool IsArrayOfArrays(JsonArray array)
+ {
+ return array.All(item => IsJsonArray(item));
+ }
+
+ ///
+ /// Checks if a JsonArray contains only objects.
+ ///
+ public static bool IsArrayOfObjects(JsonArray array)
+ {
+ return array.All(item => IsJsonObject(item));
+ }
+
+ // #endregion
+ }
+}
diff --git a/src/ToonFormat/Internal/Encode/Primitives.cs b/src/ToonFormat/Internal/Encode/Primitives.cs
new file mode 100644
index 0000000..ce666bd
--- /dev/null
+++ b/src/ToonFormat/Internal/Encode/Primitives.cs
@@ -0,0 +1,150 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using ToonFormat.Internal.Shared;
+
+namespace ToonFormat.Internal.Encode
+{
+ ///
+ /// Primitive value encoding, key encoding, and header formatting utilities.
+ /// Aligned with TypeScript encode/primitives.ts
+ ///
+ internal static class Primitives
+ {
+ // #region Primitive encoding
+
+ ///
+ /// Encodes a primitive JSON value (null, boolean, number, or string) to its TOON representation.
+ ///
+ public static string EncodePrimitive(JsonNode? value, char delimiter = Constants.COMMA)
+ {
+ if (value == null)
+ return Constants.NULL_LITERAL;
+
+ if (value is JsonValue jsonValue)
+ {
+ // Boolean
+ if (jsonValue.TryGetValue(out var boolVal))
+ return boolVal ? Constants.TRUE_LITERAL : Constants.FALSE_LITERAL;
+
+ // Number
+ if (jsonValue.TryGetValue(out var intVal))
+ return intVal.ToString();
+
+ if (jsonValue.TryGetValue(out var longVal))
+ return longVal.ToString();
+
+ if (jsonValue.TryGetValue(out var doubleVal))
+ return doubleVal.ToString("G17"); // Full precision
+
+ if (jsonValue.TryGetValue(out var decimalVal))
+ return decimalVal.ToString();
+
+ // String
+ if (jsonValue.TryGetValue(out var strVal))
+ return EncodeStringLiteral(strVal ?? string.Empty, delimiter);
+ }
+
+ return Constants.NULL_LITERAL;
+ }
+
+ ///
+ /// Encodes a string literal, adding quotes if necessary.
+ ///
+ public static string EncodeStringLiteral(string value, char delimiter = Constants.COMMA)
+ {
+ var delimiterEnum = Constants.FromDelimiterChar(delimiter);
+
+ if (ValidationShared.IsSafeUnquoted(value, delimiterEnum))
+ {
+ return value;
+ }
+
+ var escaped = StringUtils.EscapeString(value);
+ return $"{Constants.DOUBLE_QUOTE}{escaped}{Constants.DOUBLE_QUOTE}";
+ }
+
+ // #endregion
+
+ // #region Key encoding
+
+ ///
+ /// Encodes a key, adding quotes if necessary.
+ ///
+ public static string EncodeKey(string key)
+ {
+ if (ValidationShared.IsValidUnquotedKey(key))
+ {
+ return key;
+ }
+
+ var escaped = StringUtils.EscapeString(key);
+ return $"{Constants.DOUBLE_QUOTE}{escaped}{Constants.DOUBLE_QUOTE}";
+ }
+
+ // #endregion
+
+ // #region Value joining
+
+ ///
+ /// Encodes and joins an array of primitive values with the specified delimiter.
+ ///
+ public static string EncodeAndJoinPrimitives(IEnumerable values, char delimiter = Constants.COMMA)
+ {
+ var encoded = values.Select(v => EncodePrimitive(v, delimiter));
+ return string.Join(delimiter.ToString(), encoded);
+ }
+
+ // #endregion
+
+ // #region Header formatters
+
+ ///
+ /// Formats an array header with optional key, length marker, delimiter, and field names.
+ /// Examples:
+ /// - "[3]:" for unnamed array of 3 items
+ /// - "items[5]:" for named array
+ /// - "users[#2]{name,age}:" for tabular format with length marker
+ ///
+ public static string FormatHeader(
+ int length,
+ string? key = null,
+ IReadOnlyList? fields = null,
+ char? delimiter = null,
+ bool lengthMarker = false)
+ {
+ var delimiterChar = delimiter ?? Constants.DEFAULT_DELIMITER_CHAR;
+ var header = string.Empty;
+
+ // Add key if present
+ if (!string.IsNullOrEmpty(key))
+ {
+ header += EncodeKey(key);
+ }
+
+ // Add array length with optional marker and delimiter
+ var marker = lengthMarker ? Constants.HASH.ToString() : string.Empty;
+ var delimiterSuffix = delimiterChar != Constants.DEFAULT_DELIMITER_CHAR
+ ? delimiterChar.ToString()
+ : string.Empty;
+
+ header += $"{Constants.OPEN_BRACKET}{marker}{length}{delimiterSuffix}{Constants.CLOSE_BRACKET}";
+
+ // Add field names for tabular format
+ if (fields != null && fields.Count > 0)
+ {
+ var quotedFields = fields.Select(EncodeKey);
+ var fieldsStr = string.Join(delimiterChar.ToString(), quotedFields);
+ header += $"{Constants.OPEN_BRACE}{fieldsStr}{Constants.CLOSE_BRACE}";
+ }
+
+ header += Constants.COLON;
+
+ return header;
+ }
+
+ // #endregion
+ }
+}
diff --git a/src/ToonFormat/Internal/Shared/LiteralUtils.cs b/src/ToonFormat/Internal/Shared/LiteralUtils.cs
new file mode 100644
index 0000000..18f5ddc
--- /dev/null
+++ b/src/ToonFormat/Internal/Shared/LiteralUtils.cs
@@ -0,0 +1,45 @@
+#nullable enable
+using System.Globalization;
+
+namespace ToonFormat.Internal.Shared
+{
+ ///
+ /// Literal judgment utilities, aligned with TypeScript version shared/literal-utils.ts.
+ /// - IsBooleanOrNullLiteral: Determines if it is true/false/null
+ /// - IsNumericLiteral: Determines if it is a numeric literal, rejecting invalid leading zero forms
+ ///
+ internal static class LiteralUtils
+ {
+ ///
+ /// Checks if the token is a boolean or null literal: true, false, null.
+ /// Equivalent to TS: isBooleanOrNullLiteral
+ ///
+ internal static bool IsBooleanOrNullLiteral(string token)
+ {
+ return string.Equals(token, Constants.TRUE_LITERAL, StringComparison.Ordinal)
+ || string.Equals(token, Constants.FALSE_LITERAL, StringComparison.Ordinal)
+ || string.Equals(token, Constants.NULL_LITERAL, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Checks if the token is a valid numeric literal.
+ /// Rules aligned with TS:
+ /// - Rejects leading zeros (except "0" itself or decimals like "0.xxx")
+ /// - Parses successfully and is a finite number (not NaN/Infinity)
+ ///
+ internal static bool IsNumericLiteral(string token)
+ {
+ if (string.IsNullOrEmpty(token))
+ return false;
+
+ // Must not have leading zeros (except "0" itself or decimals like "0.5")
+ if (token.Length > 1 && token[0] == '0' && token[1] != '.')
+ return false;
+
+ if (!double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var num))
+ return false;
+
+ return !double.IsNaN(num) && !double.IsInfinity(num);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ToonFormat/Internal/Shared/StringUtils.cs b/src/ToonFormat/Internal/Shared/StringUtils.cs
new file mode 100644
index 0000000..3677df6
--- /dev/null
+++ b/src/ToonFormat/Internal/Shared/StringUtils.cs
@@ -0,0 +1,151 @@
+#nullable enable
+using System.Text;
+
+namespace ToonFormat.Internal.Shared
+{
+ ///
+ /// String utilities, aligned with TypeScript version shared/string-utils.ts:
+ /// - EscapeString: Escapes special characters during encoding
+ /// - UnescapeString: Restores escape sequences during decoding
+ /// - FindClosingQuote: Finds the position of the matching closing quote, considering escapes
+ /// - FindUnquotedChar: Finds the position of the target character not inside quotes
+ ///
+ internal static class StringUtils
+ {
+ ///
+ /// Escapes special characters: backslash, quotes, newlines, carriage returns, tabs.
+ /// Equivalent to TS escapeString.
+ ///
+ internal static string EscapeString(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return value ?? string.Empty;
+
+ return value
+ .Replace("\\", $"{Constants.BACKSLASH}{Constants.BACKSLASH}")
+ .Replace("\"", $"{Constants.BACKSLASH}{Constants.DOUBLE_QUOTE}")
+ .Replace("\n", $"{Constants.BACKSLASH}n")
+ .Replace("\r", $"{Constants.BACKSLASH}r")
+ .Replace("\t", $"{Constants.BACKSLASH}t");
+ }
+
+ ///
+ /// Unescapes the string, supporting \n, \t, \r, \\, \". Invalid sequences throw .
+ /// Equivalent to TS unescapeString.
+ ///
+ internal static string UnescapeString(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return value ?? string.Empty;
+
+ var sb = new StringBuilder(value.Length);
+ int i = 0;
+ while (i < value.Length)
+ {
+ var ch = value[i];
+ if (ch == Constants.BACKSLASH)
+ {
+ if (i + 1 >= value.Length)
+ throw ToonFormatException.Syntax("Invalid escape sequence: backslash at end of string");
+
+ var next = value[i + 1];
+ switch (next)
+ {
+ case 'n':
+ sb.Append(Constants.NEWLINE);
+ i += 2;
+ continue;
+ case 't':
+ sb.Append(Constants.TAB);
+ i += 2;
+ continue;
+ case 'r':
+ sb.Append(Constants.CARRIAGE_RETURN);
+ i += 2;
+ continue;
+ case '\\':
+ sb.Append(Constants.BACKSLASH);
+ i += 2;
+ continue;
+ case '"':
+ sb.Append(Constants.DOUBLE_QUOTE);
+ i += 2;
+ continue;
+ default:
+ throw ToonFormatException.Syntax($"Invalid escape sequence: \\{next}");
+ }
+ }
+
+ sb.Append(ch);
+ i++;
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Finds the position of the next double quote in the string starting from 'start', considering escapes.
+ /// Returns -1 if not found. Equivalent to TS findClosingQuote.
+ ///
+ internal static int FindClosingQuote(string content, int start)
+ {
+ int i = start + 1;
+ while (i < content.Length)
+ {
+ // Skip the next character when encountering an escape inside quotes
+ if (content[i] == Constants.BACKSLASH && i + 1 < content.Length)
+ {
+ i += 2;
+ continue;
+ }
+
+ if (content[i] == Constants.DOUBLE_QUOTE)
+ return i;
+
+ i++;
+ }
+ return -1;
+ }
+
+ ///
+ /// Finds the position of the target character not inside quotes; returns -1 if not found.
+ /// Escape sequences inside quotes are skipped. Equivalent to TS findUnquotedChar.
+ ///
+ internal static int FindUnquotedChar(string content, char target, int start = 0)
+ {
+ bool inQuotes = false;
+ int i = start;
+
+ while (i < content.Length)
+ {
+ if (inQuotes && content[i] == Constants.BACKSLASH && i + 1 < content.Length)
+ {
+ // Skip the next character for escape sequences inside quotes
+ i += 2;
+ continue;
+ }
+
+ if (content[i] == Constants.DOUBLE_QUOTE)
+ {
+ inQuotes = !inQuotes;
+ i++;
+ continue;
+ }
+
+ if (!inQuotes && content[i] == target)
+ return i;
+
+ i++;
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Generates a quoted string literal, escaping internal characters as necessary.
+ /// Note: Whether quotes are needed should be determined by the caller based on ValidationShared rules.
+ ///
+ internal static string Quote(string value)
+ {
+ return $"\"{EscapeString(value)}\"";
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ToonFormat/Internal/Shared/ValidationShared.cs b/src/ToonFormat/Internal/Shared/ValidationShared.cs
new file mode 100644
index 0000000..b3981cf
--- /dev/null
+++ b/src/ToonFormat/Internal/Shared/ValidationShared.cs
@@ -0,0 +1,95 @@
+#nullable enable
+using System;
+using System.Text.RegularExpressions;
+using ToonFormat;
+
+namespace ToonFormat.Internal.Shared
+{
+ ///
+ /// Validation utilities aligned with TypeScript version shared/validation.ts:
+ /// - IsValidUnquotedKey: Whether the key name can be without quotes
+ /// - IsSafeUnquoted: Whether the string value can be without quotes
+ /// - IsBooleanOrNullLiteral: Whether it is true/false/null
+ /// - IsNumericLike: Whether it looks like numeric text (including leading zero integers)
+ ///
+ internal static class ValidationShared
+ {
+ private static readonly Regex ValidUnquotedKeyRegex = new(
+ pattern: "^[A-Z_][\\w.]*$",
+ options: RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ private static readonly Regex NumericLikeRegex = new(
+ pattern: "^-?\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?$",
+ options: RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ private static readonly Regex LeadingZeroIntegerRegex = new(
+ pattern: "^0\\d+$",
+ options: RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ private static readonly char[] StructuralBracketsAndBraces =
+ {
+ Constants.OPEN_BRACKET,
+ Constants.CLOSE_BRACKET,
+ Constants.OPEN_BRACE,
+ Constants.CLOSE_BRACE
+ };
+
+ private static readonly char[] ControlCharacters =
+ {
+ Constants.NEWLINE,
+ Constants.CARRIAGE_RETURN,
+ Constants.TAB
+ };
+
+ /// Whether the key name can be without quotes.
+ internal static bool IsValidUnquotedKey(string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ return false;
+
+ return ValidUnquotedKeyRegex.IsMatch(key);
+ }
+
+ /// Whether the string value can be safely without quotes.
+ internal static bool IsSafeUnquoted(string value, ToonDelimiter delimiter = Constants.DEFAULT_DELIMITER_ENUM)
+ {
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ if (!string.Equals(value, value.Trim(), StringComparison.Ordinal))
+ return false;
+
+ if (LiteralUtils.IsBooleanOrNullLiteral(value) || IsNumericLike(value))
+ return false;
+
+ if (value.IndexOf(Constants.COLON) >= 0)
+ return false;
+
+ if (value.IndexOf(Constants.DOUBLE_QUOTE) >= 0 || value.IndexOf(Constants.BACKSLASH) >= 0)
+ return false;
+
+ if (value.IndexOfAny(StructuralBracketsAndBraces) >= 0)
+ return false;
+
+ if (value.IndexOfAny(ControlCharacters) >= 0)
+ return false;
+
+ var delimiterChar = Constants.ToDelimiterChar(delimiter);
+ if (value.IndexOf(delimiterChar) >= 0)
+ return false;
+
+ if (value[0] == Constants.LIST_ITEM_MARKER)
+ return false;
+
+ return true;
+ }
+
+ private static bool IsNumericLike(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ return NumericLikeRegex.IsMatch(value) || LeadingZeroIntegerRegex.IsMatch(value);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ToonFormat/Options/ToonDecodeOptions.cs b/src/ToonFormat/Options/ToonDecodeOptions.cs
new file mode 100644
index 0000000..9451869
--- /dev/null
+++ b/src/ToonFormat/Options/ToonDecodeOptions.cs
@@ -0,0 +1,20 @@
+#nullable enable
+namespace Toon.Format;
+
+///
+/// Options for decoding TOON format strings.
+///
+public class ToonDecodeOptions
+{
+ ///
+ /// Number of spaces per indentation level.
+ /// Default is 2.
+ ///
+ public int Indent { get; set; } = 2;
+
+ ///
+ /// When true, enforce strict validation of array lengths and tabular row counts.
+ /// Default is true.
+ ///
+ public bool Strict { get; set; } = true;
+}
diff --git a/src/ToonFormat/Options/ToonEncodeOptions.cs b/src/ToonFormat/Options/ToonEncodeOptions.cs
new file mode 100644
index 0000000..3f8981a
--- /dev/null
+++ b/src/ToonFormat/Options/ToonEncodeOptions.cs
@@ -0,0 +1,29 @@
+#nullable enable
+using ToonFormat;
+
+namespace Toon.Format;
+
+///
+/// Options for encoding data to TOON format.
+///
+public class ToonEncodeOptions
+{
+ ///
+ /// Number of spaces per indentation level.
+ /// Default is 2.
+ ///
+ public int Indent { get; set; } = 2;
+
+ ///
+ /// Delimiter to use for tabular array rows and inline primitive arrays.
+ /// Default is comma (,).
+ ///
+ public ToonDelimiter Delimiter { get; set; } = Constants.DEFAULT_DELIMITER_ENUM;
+
+ ///
+ /// Optional marker to prefix array lengths in headers.
+ /// When set to true, arrays render as [#N] instead of [N].
+ /// Default is false.
+ ///
+ public bool LengthMarker { get; set; } = false;
+}
diff --git a/src/ToonFormat/ToonDecoder.cs b/src/ToonFormat/ToonDecoder.cs
new file mode 100644
index 0000000..41a6e29
--- /dev/null
+++ b/src/ToonFormat/ToonDecoder.cs
@@ -0,0 +1,197 @@
+#nullable enable
+using System;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ToonFormat;
+using ToonFormat.Internal.Decode;
+
+namespace Toon.Format;
+
+///
+/// Decodes TOON-formatted strings into data structures.
+///
+public static class ToonDecoder
+{
+ ///
+ /// Decodes a TOON-formatted string into a JsonNode with default options.
+ ///
+ /// The TOON-formatted string to decode.
+ /// The decoded JsonNode object.
+ /// Thrown when toonString is null.
+ /// Thrown when the TOON format is invalid.
+ public static JsonNode? Decode(string toonString)
+ {
+ return Decode(toonString, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes a TOON-formatted string into the specified type with default options.
+ ///
+ /// Target type to deserialize into.
+ /// The TOON-formatted string to decode.
+ /// The deserialized value of type T.
+ public static T? Decode(string toonString)
+ {
+ return Decode(toonString, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes a TOON-formatted string into a JsonNode with custom options.
+ ///
+ /// The TOON-formatted string to decode.
+ /// Decoding options to customize parsing behavior.
+ /// The decoded JsonNode object.
+ /// Thrown when toonString or options is null.
+ /// Thrown when the TOON format is invalid.
+ public static JsonNode? Decode(string toonString, ToonDecodeOptions? options)
+ {
+ if (toonString == null)
+ throw new ArgumentNullException(nameof(toonString));
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ // Resolve options
+ var resolvedOptions = new ResolvedDecodeOptions
+ {
+ Indent = options.Indent,
+ Strict = options.Strict
+ };
+
+ // Scan the source text into structured lines
+ var scanResult = Scanner.ToParsedLines(toonString, resolvedOptions.Indent, resolvedOptions.Strict);
+
+ // Handle empty input
+ if (scanResult.Lines.Count == 0)
+ {
+ return new JsonObject();
+ }
+
+ // Create cursor and decode
+ var cursor = new LineCursor(scanResult.Lines, scanResult.BlankLines);
+ return Decoders.DecodeValueFromLines(cursor, resolvedOptions);
+ }
+
+ ///
+ /// Decodes a TOON-formatted string into the specified type with custom options.
+ ///
+ /// Target type to deserialize into.
+ /// The TOON-formatted string to decode.
+ /// Decoding options to customize parsing behavior.
+ /// The deserialized value of type T.
+ public static T? Decode(string toonString, ToonDecodeOptions? options)
+ {
+ var node = Decode(toonString, options);
+ if (node is null)
+ return default;
+
+ // If T is JsonNode or derived, return directly
+ if (typeof(JsonNode).IsAssignableFrom(typeof(T)))
+ {
+ return (T?)(object?)node;
+ }
+
+ // Convert JsonNode -> JSON -> T using System.Text.Json
+ var json = node.ToJsonString();
+ return JsonSerializer.Deserialize(json);
+ }
+
+ ///
+ /// Decodes TOON data from a UTF-8 byte array into a JsonNode with default options.
+ ///
+ /// UTF-8 encoded TOON text.
+ /// The decoded JsonNode object.
+ public static JsonNode? Decode(byte[] utf8Bytes)
+ {
+ return Decode(utf8Bytes, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a UTF-8 byte array into a JsonNode with custom options.
+ ///
+ /// UTF-8 encoded TOON text.
+ /// Decoding options to customize parsing behavior.
+ /// The decoded JsonNode object.
+ public static JsonNode? Decode(byte[] utf8Bytes, ToonDecodeOptions? options)
+ {
+ if (utf8Bytes == null)
+ throw new ArgumentNullException(nameof(utf8Bytes));
+ var text = Encoding.UTF8.GetString(utf8Bytes);
+ return Decode(text, options ?? new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a UTF-8 byte array into the specified type with default options.
+ ///
+ /// Target type to deserialize into.
+ /// UTF-8 encoded TOON text.
+ public static T? Decode(byte[] utf8Bytes)
+ {
+ return Decode(utf8Bytes, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a UTF-8 byte array into the specified type with custom options.
+ ///
+ /// Target type to deserialize into.
+ /// UTF-8 encoded TOON text.
+ /// Decoding options to customize parsing behavior.
+ public static T? Decode(byte[] utf8Bytes, ToonDecodeOptions? options)
+ {
+ if (utf8Bytes == null)
+ throw new ArgumentNullException(nameof(utf8Bytes));
+ var text = Encoding.UTF8.GetString(utf8Bytes);
+ return Decode(text, options ?? new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a stream (UTF-8) into a JsonNode with default options.
+ ///
+ /// The input stream to read from.
+ /// The decoded JsonNode object.
+ public static JsonNode? Decode(Stream stream)
+ {
+ return Decode(stream, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a stream (UTF-8) into a JsonNode with custom options.
+ ///
+ /// The input stream to read from.
+ /// Decoding options to customize parsing behavior.
+ /// The decoded JsonNode object.
+ public static JsonNode? Decode(Stream stream, ToonDecodeOptions? options)
+ {
+ if (stream == null)
+ throw new ArgumentNullException(nameof(stream));
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
+ var text = reader.ReadToEnd();
+ return Decode(text, options ?? new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a stream (UTF-8) into the specified type with default options.
+ ///
+ /// Target type to deserialize into.
+ /// The input stream to read from.
+ public static T? Decode(Stream stream)
+ {
+ return Decode(stream, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a stream (UTF-8) into the specified type with custom options.
+ ///
+ /// Target type to deserialize into.
+ /// The input stream to read from.
+ /// Decoding options to customize parsing behavior.
+ public static T? Decode(Stream stream, ToonDecodeOptions? options)
+ {
+ if (stream == null)
+ throw new ArgumentNullException(nameof(stream));
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
+ var text = reader.ReadToEnd();
+ return Decode(text, options ?? new ToonDecodeOptions());
+ }
+}
diff --git a/src/ToonFormat/ToonEncoder.cs b/src/ToonFormat/ToonEncoder.cs
new file mode 100644
index 0000000..175146a
--- /dev/null
+++ b/src/ToonFormat/ToonEncoder.cs
@@ -0,0 +1,193 @@
+#nullable enable
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Text;
+using ToonFormat;
+using ToonFormat.Internal.Encode;
+
+namespace Toon.Format;
+
+///
+/// Encodes data structures into TOON format.
+///
+public static class ToonEncoder
+{
+ ///
+ /// Encodes the specified object into TOON format with default options.
+ ///
+ /// The object to encode.
+ /// A TOON-formatted string representation of the object.
+ /// Thrown when data is null.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static string Encode(object? data)
+ {
+ return Encode(data, new ToonEncodeOptions());
+ }
+
+ ///
+ /// Encodes the specified value into TOON format with default options (generic overload).
+ ///
+ /// Type of the value to encode.
+ /// The value to encode.
+ /// A TOON-formatted string representation of the value.
+ public static string Encode(T data)
+ {
+ return Encode(data, new ToonEncodeOptions());
+ }
+
+ ///
+ /// Encodes the specified object into TOON format with custom options.
+ ///
+ /// The object to encode.
+ /// Encoding options to customize the output format.
+ /// A TOON-formatted string representation of the object.
+ /// Thrown when options is null.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static string Encode(object? data, ToonEncodeOptions? options)
+ {
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ // Normalize the input to JsonNode representation
+ var normalized = Normalize.NormalizeValue(data);
+
+ // Resolve options
+ var resolvedOptions = new ResolvedEncodeOptions
+ {
+ Indent = options.Indent,
+ Delimiter = Constants.ToDelimiterChar(options.Delimiter),
+ LengthMarker = options.LengthMarker
+ };
+
+ // Encode to TOON format
+ return Encoders.EncodeValue(normalized, resolvedOptions);
+ }
+
+ ///
+ /// Encodes the specified value into TOON format with custom options (generic overload).
+ ///
+ /// Type of the value to encode.
+ /// The value to encode.
+ /// Encoding options to customize the output format.
+ /// A TOON-formatted string representation of the value.
+ public static string Encode(T data, ToonEncodeOptions? options)
+ {
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ var normalized = Normalize.NormalizeValue(data);
+
+ var resolvedOptions = new ResolvedEncodeOptions
+ {
+ Indent = options.Indent,
+ Delimiter = Constants.ToDelimiterChar(options.Delimiter),
+ LengthMarker = options.LengthMarker
+ };
+
+ return Encoders.EncodeValue(normalized, resolvedOptions);
+ }
+
+ ///
+ /// Encodes the specified object into UTF-8 bytes with default options.
+ ///
+ /// The object to encode.
+ /// UTF-8 encoded TOON bytes.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static byte[] EncodeToBytes(object? data)
+ {
+ return EncodeToBytes(data, new ToonEncodeOptions());
+ }
+
+ ///
+ /// Encodes the specified object into UTF-8 bytes with custom options.
+ ///
+ /// The object to encode.
+ /// Encoding options to customize the output format.
+ /// UTF-8 encoded TOON bytes.
+ /// Thrown when options is null.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static byte[] EncodeToBytes(object? data, ToonEncodeOptions? options)
+ {
+ var text = Encode(data, options);
+ return Encoding.UTF8.GetBytes(text);
+ }
+
+ ///
+ /// Encodes the specified value into UTF-8 bytes with default options (generic overload).
+ ///
+ /// Type of the value to encode.
+ /// The value to encode.
+ /// UTF-8 encoded TOON bytes.
+ public static byte[] EncodeToBytes(T data)
+ {
+ var text = Encode(data, new ToonEncodeOptions());
+ return Encoding.UTF8.GetBytes(text);
+ }
+
+ ///
+ /// Encodes the specified value into UTF-8 bytes with custom options (generic overload).
+ ///
+ /// Type of the value to encode.
+ /// The value to encode.
+ /// Encoding options to customize the output format.
+ /// UTF-8 encoded TOON bytes.
+ public static byte[] EncodeToBytes(T data, ToonEncodeOptions? options)
+ {
+ var text = Encode(data, options);
+ return Encoding.UTF8.GetBytes(text);
+ }
+
+ ///
+ /// Encodes the specified object and writes UTF-8 bytes to the destination stream using default options.
+ ///
+ /// The object to encode.
+ /// The destination stream to write to. The stream is not disposed.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static void EncodeToStream(object? data, Stream destination)
+ {
+ EncodeToStream(data, destination, new ToonEncodeOptions());
+ }
+
+ ///
+ /// Encodes the specified object and writes UTF-8 bytes to the destination stream using custom options.
+ ///
+ /// The object to encode.
+ /// The destination stream to write to. The stream is not disposed.
+ /// Encoding options to customize the output format.
+ /// Thrown when destination or options is null.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static void EncodeToStream(object? data, Stream destination, ToonEncodeOptions? options)
+ {
+ if (destination == null)
+ throw new ArgumentNullException(nameof(destination));
+ var bytes = EncodeToBytes(data, options);
+ destination.Write(bytes, 0, bytes.Length);
+ }
+
+ ///
+ /// Encodes the specified value and writes UTF-8 bytes to the destination stream using default options (generic overload).
+ ///
+ /// Type of the value to encode.
+ /// The value to encode.
+ /// The destination stream to write to. The stream is not disposed.
+ public static void EncodeToStream(T data, Stream destination)
+ {
+ EncodeToStream(data, destination, new ToonEncodeOptions());
+ }
+
+ ///
+ /// Encodes the specified value and writes UTF-8 bytes to the destination stream using custom options (generic overload).
+ ///
+ /// Type of the value to encode.
+ /// The value to encode.
+ /// The destination stream to write to. The stream is not disposed.
+ /// Encoding options to customize the output format.
+ public static void EncodeToStream(T data, Stream destination, ToonEncodeOptions? options)
+ {
+ if (destination == null)
+ throw new ArgumentNullException(nameof(destination));
+ var bytes = EncodeToBytes(data, options);
+ destination.Write(bytes, 0, bytes.Length);
+ }
+}
diff --git a/src/ToonFormat/ToonFormat.csproj b/src/ToonFormat/ToonFormat.csproj
index 30c49e9..c7b5e62 100644
--- a/src/ToonFormat/ToonFormat.csproj
+++ b/src/ToonFormat/ToonFormat.csproj
@@ -22,7 +22,13 @@
-
+
+
+
+
+
+
+
diff --git a/src/ToonFormat/ToonFormatException.cs b/src/ToonFormat/ToonFormatException.cs
new file mode 100644
index 0000000..9396875
--- /dev/null
+++ b/src/ToonFormat/ToonFormatException.cs
@@ -0,0 +1,145 @@
+#nullable enable
+using System;
+using System.Text;
+
+namespace ToonFormat
+{
+ ///
+ /// Exception thrown when TOON format parsing or encoding fails.
+ ///
+ public sealed class ToonFormatException : Exception
+ {
+ /// Error type (syntax, range, validation, indentation, delimiter, unknown).
+ public ToonErrorKind Kind { get; }
+
+ /// 1-based line number.
+ public int? LineNumber { get; }
+
+ /// 1-based column number.
+ public int? ColumnNumber { get; }
+
+ /// Original line text where the error occurred (may be truncated).
+ public string? SourceLine { get; }
+
+ /// Indentation depth (optional, for debugging).
+ public int? Depth { get; }
+
+ /// Constructs the exception, automatically concatenating messages with position and line context.
+ public ToonFormatException(
+ ToonErrorKind kind,
+ string message,
+ int? lineNumber = null,
+ int? columnNumber = null,
+ string? sourceLine = null,
+ int? depth = null,
+ Exception? inner = null)
+ : base(BuildMessage(kind, message, lineNumber, columnNumber, sourceLine), inner)
+ {
+ Kind = kind;
+ LineNumber = lineNumber;
+ ColumnNumber = columnNumber;
+ SourceLine = sourceLine;
+ Depth = depth;
+ }
+
+ /// Syntax error factory method.
+ public static ToonFormatException Syntax(
+ string message,
+ int? lineNumber = null,
+ int? columnNumber = null,
+ string? sourceLine = null,
+ int? depth = null,
+ Exception? inner = null)
+ => new(ToonErrorKind.Syntax, message, lineNumber, columnNumber, sourceLine, depth, inner);
+
+ /// Range error factory method (e.g., count mismatches).
+ public static ToonFormatException Range(
+ string message,
+ int? lineNumber = null,
+ int? columnNumber = null,
+ string? sourceLine = null,
+ int? depth = null,
+ Exception? inner = null)
+ => new(ToonErrorKind.Range, message, lineNumber, columnNumber, sourceLine, depth, inner);
+
+ /// Validation error factory method (extra lines/empty lines/structural rules).
+ public static ToonFormatException Validation(
+ string message,
+ int? lineNumber = null,
+ int? columnNumber = null,
+ string? sourceLine = null,
+ int? depth = null,
+ Exception? inner = null)
+ => new(ToonErrorKind.Validation, message, lineNumber, columnNumber, sourceLine, depth, inner);
+
+ /// Indentation error factory method (in strict mode, indentation must be multiples of indent and cannot contain TAB).
+ public static ToonFormatException Indentation(
+ string message,
+ int? lineNumber = null,
+ int? columnNumber = null,
+ string? sourceLine = null,
+ int? depth = null,
+ Exception? inner = null)
+ => new(ToonErrorKind.Indentation, message, lineNumber, columnNumber, sourceLine, depth, inner);
+
+ /// Delimiter-related error factory method.
+ public static ToonFormatException Delimiter(
+ string message,
+ int? lineNumber = null,
+ int? columnNumber = null,
+ string? sourceLine = null,
+ int? depth = null,
+ Exception? inner = null)
+ => new(ToonErrorKind.Delimiter, message, lineNumber, columnNumber, sourceLine, depth, inner);
+
+ private static string BuildMessage(
+ ToonErrorKind kind,
+ string message,
+ int? lineNumber,
+ int? columnNumber,
+ string? sourceLine)
+ {
+ var sb = new StringBuilder();
+ sb.Append('[').Append(kind).Append("] ").Append(message);
+
+ if (lineNumber is not null)
+ sb.Append(" (Line ").Append(lineNumber.Value).Append(')');
+ if (columnNumber is not null)
+ sb.Append(" (Column ").Append(columnNumber.Value).Append(')');
+
+ if (!string.IsNullOrEmpty(sourceLine))
+ {
+ sb.AppendLine();
+ sb.Append(" > ").Append(sourceLine);
+
+ if (columnNumber is not null && columnNumber.Value > 0)
+ {
+ sb.AppendLine();
+ sb.Append(" ");
+ // Caret pointing to the column position
+ var caretPos = Math.Max(1, columnNumber.Value);
+ sb.Append(new string(' ', caretPos - 1)).Append('^');
+ }
+ }
+
+ return sb.ToString();
+ }
+ }
+
+ /// TOON error type classification.
+ public enum ToonErrorKind
+ {
+ /// Syntax error: illegal tokens or structures encountered during scanning/parsing phase.
+ Syntax,
+ /// Range error: count mismatches (e.g., [N] does not match actual item count).
+ Range,
+ /// Validation error: structural/rule validation failure in strict mode (e.g., extra lines, empty lines).
+ Validation,
+ /// Indentation error: indentation is not a multiple of Indent or contains TAB.
+ Indentation,
+ /// Delimiter error: fields/values contain disallowed delimiters or delimiter inference failed.
+ Delimiter,
+ /// Unknown error: unclassified exceptions.
+ Unknown
+ }
+}
diff --git a/tests/ToonFormat.Tests/ToonDecoderTests.cs b/tests/ToonFormat.Tests/ToonDecoderTests.cs
new file mode 100644
index 0000000..fbcbc1b
--- /dev/null
+++ b/tests/ToonFormat.Tests/ToonDecoderTests.cs
@@ -0,0 +1,170 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Toon.Format;
+
+namespace ToonFormat.Tests;
+
+///
+/// Tests for decoding TOON format strings.
+///
+public class ToonDecoderTests
+{
+ [Fact]
+ public void Decode_SimpleObject_ReturnsValidJson()
+ {
+ // Arrange
+ var toonString = "name: Alice\nage: 30";
+
+ // Act
+ var result = ToonDecoder.Decode(toonString);
+
+ // Assert
+ Assert.NotNull(result);
+ var obj = result.AsObject();
+ Assert.Equal("Alice", obj["name"]?.GetValue());
+ Assert.Equal(30.0, obj["age"]?.GetValue());
+ }
+
+ [Fact]
+ public void Decode_PrimitiveTypes_ReturnsCorrectValues()
+ {
+ // String
+ var stringResult = ToonDecoder.Decode("hello");
+ Assert.Equal("hello", stringResult?.GetValue());
+
+ // Number - JSON defaults to double
+ var numberResult = ToonDecoder.Decode("42");
+ Assert.Equal(42.0, numberResult?.GetValue());
+
+ // Boolean
+ var boolResult = ToonDecoder.Decode("true");
+ Assert.True(boolResult?.GetValue());
+
+ // Null
+ var nullResult = ToonDecoder.Decode("null");
+ Assert.Null(nullResult);
+ }
+
+ [Fact]
+ public void Decode_PrimitiveArray_ReturnsValidArray()
+ {
+ // Arrange
+ var toonString = "numbers[5]: 1, 2, 3, 4, 5";
+
+ // Act
+ var result = ToonDecoder.Decode(toonString);
+
+ // Assert
+ Assert.NotNull(result);
+ var obj = result.AsObject();
+ var numbers = obj["numbers"]?.AsArray();
+ Assert.NotNull(numbers);
+ Assert.Equal(5, numbers.Count);
+ Assert.Equal(1.0, numbers[0]?.GetValue());
+ Assert.Equal(5.0, numbers[4]?.GetValue());
+ }
+
+ [Fact]
+ public void Decode_TabularArray_ReturnsValidStructure()
+ {
+ // Arrange - using list array format instead
+ var toonString = @"employees[3]:
+ - id: 1
+ name: Alice
+ salary: 50000
+ - id: 2
+ name: Bob
+ salary: 60000
+ - id: 3
+ name: Charlie
+ salary: 55000";
+
+ // Act
+ var result = ToonDecoder.Decode(toonString);
+
+ // Assert
+ Assert.NotNull(result);
+ var obj = result.AsObject();
+ var employees = obj["employees"]?.AsArray();
+ Assert.NotNull(employees);
+ Assert.Equal(3, employees.Count);
+ Assert.Equal(1.0, employees[0]?["id"]?.GetValue());
+ Assert.Equal("Alice", employees[0]?["name"]?.GetValue());
+ }
+
+ [Fact]
+ public void Decode_NestedObject_ReturnsValidStructure()
+ {
+ // Arrange
+ var toonString = @"user:
+ name: Alice
+ address:
+ city: New York
+ zip: 10001";
+
+ // Act
+ var result = ToonDecoder.Decode(toonString);
+
+ // Assert
+ Assert.NotNull(result);
+ var user = result["user"]?.AsObject();
+ Assert.NotNull(user);
+ Assert.Equal("Alice", user["name"]?.GetValue());
+ var address = user["address"]?.AsObject();
+ Assert.NotNull(address);
+ Assert.Equal("New York", address["city"]?.GetValue());
+ }
+
+ [Fact]
+ public void Decode_WithStrictOption_ValidatesArrayLength()
+ {
+ // Arrange - array declares 5 items but only provides 3
+ var toonString = "numbers[5]: 1, 2, 3";
+ var options = new ToonDecodeOptions { Strict = true };
+
+ // Act & Assert
+ Assert.Throws(() => ToonDecoder.Decode(toonString, options));
+ }
+
+ [Fact]
+ public void Decode_WithNonStrictOption_AllowsLengthMismatch()
+ {
+ // Arrange - array declares 5 items but only provides 3
+ var toonString = "numbers[5]: 1, 2, 3";
+ var options = new ToonDecodeOptions { Strict = false };
+
+ // Act
+ var result = ToonDecoder.Decode(toonString, options);
+
+ // Assert
+ Assert.NotNull(result);
+ var obj = result.AsObject();
+ var numbers = obj["numbers"]?.AsArray();
+ Assert.NotNull(numbers);
+ Assert.Equal(3, numbers.Count);
+ }
+
+ [Fact]
+ public void Decode_InvalidFormat_ThrowsToonFormatException()
+ {
+ // Arrange - array length mismatch with strict mode
+ var invalidToon = "items[10]: 1, 2, 3";
+ var options = new ToonDecodeOptions { Strict = true };
+
+ // Act & Assert
+ Assert.Throws(() => ToonDecoder.Decode(invalidToon, options));
+ }
+
+ [Fact]
+ public void Decode_EmptyString_ReturnsEmptyObject()
+ {
+ // Arrange
+ var emptyString = "";
+
+ // Act
+ var result = ToonDecoder.Decode(emptyString);
+
+ // Assert - empty string returns empty array
+ Assert.NotNull(result);
+ }
+}
diff --git a/tests/ToonFormat.Tests/ToonEncoderTests.cs b/tests/ToonFormat.Tests/ToonEncoderTests.cs
new file mode 100644
index 0000000..6f9e70b
--- /dev/null
+++ b/tests/ToonFormat.Tests/ToonEncoderTests.cs
@@ -0,0 +1,154 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Toon.Format;
+
+namespace ToonFormat.Tests;
+
+///
+/// Tests for encoding data to TOON format.
+///
+public class ToonEncoderTests
+{
+ [Fact]
+ public void Encode_SimpleObject_ReturnsValidToon()
+ {
+ // Arrange
+ var data = new { name = "Alice", age = 30 };
+
+ // Act
+ var result = ToonEncoder.Encode(data);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Contains("name:", result);
+ Assert.Contains("age:", result);
+ }
+
+ [Fact]
+ public void Encode_PrimitiveTypes_ReturnsValidToon()
+ {
+ // String
+ var stringResult = ToonEncoder.Encode("hello");
+ Assert.Equal("hello", stringResult);
+
+ // Number
+ var numberResult = ToonEncoder.Encode(42);
+ Assert.Equal("42", numberResult);
+
+ // Boolean
+ var boolResult = ToonEncoder.Encode(true);
+ Assert.Equal("true", boolResult);
+
+ // Null
+ var nullResult = ToonEncoder.Encode(null);
+ Assert.Equal("null", nullResult);
+ }
+
+ [Fact]
+ public void Encode_Array_ReturnsValidToon()
+ {
+ // Arrange
+ var data = new { numbers = new[] { 1, 2, 3, 4, 5 } };
+
+ // Act
+ var result = ToonEncoder.Encode(data);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Contains("numbers[", result);
+ }
+
+ [Fact]
+ public void Encode_TabularArray_ReturnsValidToon()
+ {
+ // Arrange
+ var employees = new[]
+ {
+ new { id = 1, name = "Alice", salary = 50000 },
+ new { id = 2, name = "Bob", salary = 60000 },
+ new { id = 3, name = "Charlie", salary = 55000 }
+ };
+ var data = new { employees };
+
+ // Act
+ var result = ToonEncoder.Encode(data);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Contains("employees[", result);
+ Assert.Contains("id", result);
+ Assert.Contains("name", result);
+ Assert.Contains("salary", result);
+ }
+
+ [Fact]
+ public void Encode_WithCustomIndent_UsesCorrectIndentation()
+ {
+ // Arrange
+ var data = new { outer = new { inner = "value" } };
+ var options = new ToonEncodeOptions { Indent = 4 };
+
+ // Act
+ var result = ToonEncoder.Encode(data, options);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Contains("outer:", result);
+ }
+
+ [Fact]
+ public void Encode_WithCustomDelimiter_UsesCorrectDelimiter()
+ {
+ // Arrange
+ var data = new { numbers = new[] { 1, 2, 3 } };
+ var options = new ToonEncodeOptions { Delimiter = ToonDelimiter.TAB };
+
+ // Act
+ var result = ToonEncoder.Encode(data, options);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Contains("numbers[", result);
+ }
+
+ [Fact]
+ public void Encode_WithLengthMarker_IncludesHashSymbol()
+ {
+ // Arrange
+ var data = new { items = new[] { 1, 2, 3 } };
+ var options = new ToonEncodeOptions { LengthMarker = true };
+
+ // Act
+ var result = ToonEncoder.Encode(data, options);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Contains("[#", result);
+ }
+
+ [Fact]
+ public void Encode_NestedStructures_ReturnsValidToon()
+ {
+ // Arrange
+ var data = new
+ {
+ user = new
+ {
+ name = "Alice",
+ address = new
+ {
+ city = "New York",
+ zip = "10001"
+ }
+ }
+ };
+
+ // Act
+ var result = ToonEncoder.Encode(data);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Contains("user:", result);
+ Assert.Contains("address:", result);
+ }
+}
diff --git a/tests/ToonFormat.Tests/ToonRoundTripTests.cs b/tests/ToonFormat.Tests/ToonRoundTripTests.cs
new file mode 100644
index 0000000..251ee95
--- /dev/null
+++ b/tests/ToonFormat.Tests/ToonRoundTripTests.cs
@@ -0,0 +1,84 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Toon.Format;
+
+namespace ToonFormat.Tests;
+
+///
+/// Round-trip tests to verify encoding and decoding preserve data integrity.
+///
+public class ToonRoundTripTests
+{
+ [Fact]
+ public void RoundTrip_SimpleObject_PreservesData()
+ {
+ // Arrange
+ var original = new { name = "Alice", age = 30, active = true };
+
+ // Act
+ var encoded = ToonEncoder.Encode(original);
+ var decoded = ToonDecoder.Decode(encoded);
+
+ // Assert
+ Assert.NotNull(decoded);
+ var obj = decoded.AsObject();
+ Assert.Equal("Alice", obj["name"]?.GetValue());
+ Assert.Equal(30.0, obj["age"]?.GetValue());
+ Assert.True(obj["active"]?.GetValue());
+ }
+
+ [Fact]
+ public void RoundTrip_Array_PreservesData()
+ {
+ // Arrange
+ var original = new { numbers = new[] { 1, 2, 3, 4, 5 } };
+
+ // Act
+ var encoded = ToonEncoder.Encode(original);
+ var decoded = ToonDecoder.Decode(encoded);
+
+ // Assert
+ Assert.NotNull(decoded);
+ var numbers = decoded["numbers"]?.AsArray();
+ Assert.NotNull(numbers);
+ Assert.Equal(5, numbers.Count);
+ for (int i = 0; i < 5; i++)
+ {
+ Assert.Equal((double)(i + 1), numbers[i]?.GetValue());
+ }
+ }
+
+ [Fact]
+ public void RoundTrip_ComplexStructure_PreservesData()
+ {
+ // Arrange
+ var original = new
+ {
+ users = new[]
+ {
+ new { id = 1, name = "Alice", email = "alice@example.com" },
+ new { id = 2, name = "Bob", email = "bob@example.com" }
+ },
+ metadata = new
+ {
+ total = 2,
+ timestamp = "2025-01-01T00:00:00Z"
+ }
+ };
+
+ // Act
+ var encoded = ToonEncoder.Encode(original);
+ var decoded = ToonDecoder.Decode(encoded);
+
+ // Assert
+ Assert.NotNull(decoded);
+ var users = decoded["users"]?.AsArray();
+ Assert.NotNull(users);
+ Assert.Equal(2, users.Count);
+ Assert.Equal("Alice", users[0]?["name"]?.GetValue());
+
+ var metadata = decoded["metadata"]?.AsObject();
+ Assert.NotNull(metadata);
+ Assert.Equal(2.0, metadata["total"]?.GetValue());
+ }
+}
diff --git a/tests/ToonFormat.Tests/UnitTest1.cs b/tests/ToonFormat.Tests/UnitTest1.cs
deleted file mode 100644
index bea8580..0000000
--- a/tests/ToonFormat.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using Toon.Format;
-
-namespace ToonFormat.Tests;
-
-public class ToonEncoderTests
-{
- [Fact]
- public void Encode_ThrowsNotImplementedException()
- {
- // Arrange
- var data = new { name = "test" };
-
- // Act & Assert
- Assert.Throws(() => ToonEncoder.Encode(data));
- }
-}
-
-public class ToonDecoderTests
-{
- [Fact]
- public void Decode_ThrowsNotImplementedException()
- {
- // Arrange
- var toonString = "name:test";
-
- // Act & Assert
- Assert.Throws(() => ToonDecoder.Decode(toonString));
- }
-}
From 0fce37f2cc6d4564dabd636c32247a54e43b8bb1 Mon Sep 17 00:00:00 2001
From: token <61819790+239573049@users.noreply.github.com>
Date: Thu, 6 Nov 2025 16:55:01 +0800
Subject: [PATCH 2/9] Update src/ToonFormat/Constants.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/ToonFormat/Constants.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/ToonFormat/Constants.cs b/src/ToonFormat/Constants.cs
index e36c52d..50dfe73 100644
--- a/src/ToonFormat/Constants.cs
+++ b/src/ToonFormat/Constants.cs
@@ -37,7 +37,6 @@ public static class Constants
public const char CARRIAGE_RETURN = '\r';
public const char TAB = '\t';
-
// #region Delimiter defaults and mapping
public const ToonDelimiter DEFAULT_DELIMITER_ENUM = ToonDelimiter.COMMA;
From 9a1517c0f066c5591da1c6ed7d76d1f420c7a45d Mon Sep 17 00:00:00 2001
From: token <61819790+239573049@users.noreply.github.com>
Date: Thu, 6 Nov 2025 16:56:16 +0800
Subject: [PATCH 3/9] Update src/ToonFormat/Internal/Encode/Normalize.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/ToonFormat/Internal/Encode/Normalize.cs | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/src/ToonFormat/Internal/Encode/Normalize.cs b/src/ToonFormat/Internal/Encode/Normalize.cs
index 72a9ee8..c094c6f 100644
--- a/src/ToonFormat/Internal/Encode/Normalize.cs
+++ b/src/ToonFormat/Internal/Encode/Normalize.cs
@@ -102,13 +102,10 @@ internal static class Normalize
var type = value.GetType();
var properties = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
- foreach (var prop in properties)
+ foreach (var prop in properties.Where(prop => prop.CanRead))
{
- if (prop.CanRead)
- {
- var propValue = prop.GetValue(value);
- jsonObject[prop.Name] = NormalizeValue(propValue);
- }
+ var propValue = prop.GetValue(value);
+ jsonObject[prop.Name] = NormalizeValue(propValue);
}
return jsonObject;
From 12a80fd6e0e11535364463a3112c6118e8b7c803 Mon Sep 17 00:00:00 2001
From: token <61819790+239573049@users.noreply.github.com>
Date: Tue, 18 Nov 2025 02:46:24 +0800
Subject: [PATCH 4/9] Update src/ToonFormat/Internal/Encode/Encoders.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/ToonFormat/Internal/Encode/Encoders.cs | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/src/ToonFormat/Internal/Encode/Encoders.cs b/src/ToonFormat/Internal/Encode/Encoders.cs
index 8f05471..2be4127 100644
--- a/src/ToonFormat/Internal/Encode/Encoders.cs
+++ b/src/ToonFormat/Internal/Encode/Encoders.cs
@@ -367,12 +367,9 @@ public static void EncodeObjectAsListItem(JsonObject obj, LineWriter writer, int
{
// Fall back to list format for non-uniform arrays of objects
writer.PushListItem(depth, $"{encodedKey}{Constants.OPEN_BRACKET}{arr.Count}{Constants.CLOSE_BRACKET}{Constants.COLON}");
- foreach (var item in arr)
+ foreach (var itemObj in arr.OfType())
{
- if (item is JsonObject itemObj)
- {
- EncodeObjectAsListItem(itemObj, writer, depth + 1, options);
- }
+ EncodeObjectAsListItem(itemObj, writer, depth + 1, options);
}
}
}
From 475a2da94941b8b4862fcc4a4e4e478250f3b619 Mon Sep 17 00:00:00 2001
From: token <239573049@qq.com>
Date: Tue, 18 Nov 2025 03:00:14 +0800
Subject: [PATCH 5/9] Optimize parsing performance and fix potential issues
Optimize the performance of the `ParseDelimitedValues` and `ToParsedLines`
methods: - Reduce memory allocation by using `StringBuilder` and
`ReadOnlySpan`. - Allocate capacity in advance to avoid dynamic
resizing. - Replace string operations with more efficient logic. Fix the code
formatting and logic of `IsArrayHeaderAfterHyphen` and `ParseStringLiteral`:
- Adjust the format of the `return` statement. - Eliminate redundant code.
New `MapRowValuesToPrimitives` method: - Supports mapping string arrays to
JSON primitive values. Other minor optimizations: - Fixed the logic for
handling blank lines. - Replaced some string operations with more efficient
implementations. - Fixed the issue of character encoding in comments,
improving code readability.
---
src/ToonFormat/Internal/Decode/Parser.cs | 41 ++++-----
src/ToonFormat/Internal/Decode/Scanner.cs | 100 ++++++++++++++--------
2 files changed, 82 insertions(+), 59 deletions(-)
diff --git a/src/ToonFormat/Internal/Decode/Parser.cs b/src/ToonFormat/Internal/Decode/Parser.cs
index 258662f..0c15630 100644
--- a/src/ToonFormat/Internal/Decode/Parser.cs
+++ b/src/ToonFormat/Internal/Decode/Parser.cs
@@ -101,8 +101,8 @@ internal static class Parser
if (bracketStart > 0)
{
var rawKey = content.Substring(0, bracketStart).Trim();
- key = rawKey.StartsWith(Constants.DOUBLE_QUOTE.ToString())
- ? ParseStringLiteral(rawKey)
+ key = rawKey.StartsWith(Constants.DOUBLE_QUOTE.ToString())
+ ? ParseStringLiteral(rawKey)
: rawKey;
}
@@ -202,52 +202,49 @@ private static BracketSegmentResult ParseBracketSegment(string seg, char default
///
public static List ParseDelimitedValues(string input, char delimiter)
{
- var values = new List();
- var current = string.Empty;
+ var values = new List(16); // Ô¤·ÖÅäһЩÈÝÁ¿
+ var current = new System.Text.StringBuilder(input.Length);
bool inQuotes = false;
- int i = 0;
- while (i < input.Length)
+ for (int i = 0; i < input.Length; i++)
{
- var ch = input[i];
+ char ch = input[i];
- if (ch == Constants.BACKSLASH && i + 1 < input.Length && inQuotes)
+ if (ch == Constants.BACKSLASH && inQuotes && i + 1 < input.Length)
{
- // Escape sequence in quoted string
- current += ch.ToString() + input[i + 1];
- i += 2;
+ // תÒå´¦Àí
+ current.Append(ch);
+ current.Append(input[i + 1]);
+ i++;
continue;
}
if (ch == Constants.DOUBLE_QUOTE)
{
inQuotes = !inQuotes;
- current += ch;
- i++;
+ current.Append(ch);
continue;
}
if (ch == delimiter && !inQuotes)
{
- values.Add(current.Trim());
- current = string.Empty;
- i++;
+ values.Add(current.ToString().Trim());
+ current.Clear();
continue;
}
- current += ch;
- i++;
+ current.Append(ch);
}
- // Add last value
- if (!string.IsNullOrEmpty(current) || values.Count > 0)
+ if (current.Length > 0 || values.Count > 0)
{
- values.Add(current.Trim());
+ values.Add(current.ToString().Trim());
}
return values;
}
+
///
/// Maps an array of string tokens to JSON primitive values.
///
@@ -408,7 +405,7 @@ public static KeyParseResult ParseKeyToken(string content, int start)
///
public static bool IsArrayHeaderAfterHyphen(string content)
{
- return content.Trim().StartsWith(Constants.OPEN_BRACKET.ToString())
+ return content.Trim().StartsWith(Constants.OPEN_BRACKET.ToString())
&& StringUtils.FindUnquotedChar(content, Constants.COLON) != -1;
}
diff --git a/src/ToonFormat/Internal/Decode/Scanner.cs b/src/ToonFormat/Internal/Decode/Scanner.cs
index 096e1fd..2fa9923 100644
--- a/src/ToonFormat/Internal/Decode/Scanner.cs
+++ b/src/ToonFormat/Internal/Decode/Scanner.cs
@@ -109,79 +109,105 @@ internal static class Scanner
///
public static ScanResult ToParsedLines(string source, int indentSize, bool strict)
{
+ int estimatedLines = 1;
+
+ for (int i = 0; i < source.Length; i++)
+ {
+ if (source[i] == '\n')
+ estimatedLines++;
+ }
+ var parsed = new List(estimatedLines);
+ var blankLines = new List(Math.Max(4, estimatedLines / 4));
if (string.IsNullOrWhiteSpace(source))
{
- return new ScanResult();
+ return new ScanResult { Lines = parsed, BlankLines = blankLines };
}
-
- var lines = source.Split('\n');
- var parsed = new List();
- var blankLines = new List();
-
- for (int i = 0; i < lines.Length; i++)
+ ReadOnlySpan span = source.AsSpan();
+ int lineNumber = 0;
+ while (!span.IsEmpty)
{
- var raw = lines[i];
- var lineNumber = i + 1;
+ lineNumber++;
+ // ÕÒµ½ÕâÒ»ÐеĽáÊøÎ»ÖÃ
+ int newlineIdx = span.IndexOf('\n');
+ ReadOnlySpan lineSpan;
+ if (newlineIdx >= 0)
+ {
+ lineSpan = span.Slice(0, newlineIdx);
+ span = span.Slice(newlineIdx + 1);
+ }
+ else
+ {
+ lineSpan = span;
+ span = ReadOnlySpan.Empty;
+ }
+ // È¥µô½áβµÄ»»ÐзûºÍ»Ø³µ
+ if (!lineSpan.IsEmpty && lineSpan[lineSpan.Length - 1] == '\r')
+ {
+ lineSpan = lineSpan.Slice(0, lineSpan.Length - 1);
+ }
+ // ¼ÆËãËõ½ø
int indent = 0;
-
- while (indent < raw.Length && raw[indent] == Constants.SPACE)
+ while (indent < lineSpan.Length && lineSpan[indent] == Constants.SPACE)
{
indent++;
}
-
- var content = raw.Substring(indent);
-
- // Track blank lines
- if (string.IsNullOrWhiteSpace(content))
+ ReadOnlySpan contentSpan = lineSpan.Slice(indent);
+ if (contentSpan.IsWhiteSpace())
{
var depth = ComputeDepthFromIndent(indent, indentSize);
- blankLines.Add(new BlankLineInfo
- {
- LineNumber = lineNumber,
- Indent = indent,
- Depth = depth
+ blankLines.Add(new BlankLineInfo
+ {
+ LineNumber = lineNumber,
+ Indent = indent,
+ Depth = depth
});
continue;
}
-
var lineDepth = ComputeDepthFromIndent(indent, indentSize);
-
- // Strict mode validation
if (strict)
{
- // Find the full leading whitespace region (spaces and tabs)
int wsEnd = 0;
- while (wsEnd < raw.Length && (raw[wsEnd] == Constants.SPACE || raw[wsEnd] == Constants.TAB))
+ while (wsEnd < lineSpan.Length &&
+ (lineSpan[wsEnd] == Constants.SPACE || lineSpan[wsEnd] == Constants.TAB))
{
wsEnd++;
}
-
- // Check for tabs in leading whitespace (before actual content)
- if (raw.Substring(0, wsEnd).Contains(Constants.TAB))
+ for (int j = 0; j < wsEnd; j++)
{
- throw ToonFormatException.Syntax($"Line {lineNumber}: Tabs are not allowed in indentation in strict mode");
+ if (lineSpan[j] == Constants.TAB)
+ {
+ throw ToonFormatException.Syntax(
+ $"Line {lineNumber}: Tabs are not allowed in indentation in strict mode");
+ }
}
-
- // Check for exact multiples of indentSize
if (indent > 0 && indent % indentSize != 0)
{
- throw ToonFormatException.Syntax($"Line {lineNumber}: Indentation must be exact multiple of {indentSize}, but found {indent} spaces");
+ throw ToonFormatException.Syntax(
+ $"Line {lineNumber}: Indentation must be exact multiple of {indentSize}, but found {indent} spaces");
}
}
-
parsed.Add(new ParsedLine
{
- Raw = raw,
+ Raw = new string(lineSpan),
Indent = indent,
- Content = content,
+ Content = new string(contentSpan),
Depth = lineDepth,
LineNumber = lineNumber
});
}
-
return new ScanResult { Lines = parsed, BlankLines = blankLines };
}
+ private static bool IsWhiteSpace(this ReadOnlySpan span)
+ {
+ for (int i = 0; i < span.Length; i++)
+ {
+ if (!char.IsWhiteSpace(span[i]))
+ return false;
+ }
+ return true;
+ }
+
private static int ComputeDepthFromIndent(int indentSpaces, int indentSize)
{
return indentSpaces / indentSize;
From 532ee00bc2746df900b0384ea8b30f4945d8553b Mon Sep 17 00:00:00 2001
From: token <61819790+239573049@users.noreply.github.com>
Date: Tue, 18 Nov 2025 03:01:02 +0800
Subject: [PATCH 6/9] Update src/ToonFormat/Internal/Encode/Encoders.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/ToonFormat/Internal/Encode/Encoders.cs | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/ToonFormat/Internal/Encode/Encoders.cs b/src/ToonFormat/Internal/Encode/Encoders.cs
index 2be4127..70dfee1 100644
--- a/src/ToonFormat/Internal/Encode/Encoders.cs
+++ b/src/ToonFormat/Internal/Encode/Encoders.cs
@@ -287,10 +287,9 @@ private static void WriteTabularRows(
int depth,
ResolvedEncodeOptions options)
{
- foreach (var row in rows)
+ foreach (var joinedValue in rows.Select(row =>
+ Primitives.EncodeAndJoinPrimitives(header.Select(key => row[key]).ToList(), options.Delimiter)))
{
- var values = header.Select(key => row[key]).ToList();
- var joinedValue = Primitives.EncodeAndJoinPrimitives(values, options.Delimiter);
writer.Push(depth, joinedValue);
}
}
From 670ae1f8e1f2d4396335cb0d13860ffb023b05d9 Mon Sep 17 00:00:00 2001
From: token <61819790+239573049@users.noreply.github.com>
Date: Tue, 18 Nov 2025 03:02:36 +0800
Subject: [PATCH 7/9] Update src/ToonFormat/Internal/Encode/Normalize.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/ToonFormat/Internal/Encode/Normalize.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/ToonFormat/Internal/Encode/Normalize.cs b/src/ToonFormat/Internal/Encode/Normalize.cs
index c094c6f..2198cb5 100644
--- a/src/ToonFormat/Internal/Encode/Normalize.cs
+++ b/src/ToonFormat/Internal/Encode/Normalize.cs
@@ -137,11 +137,11 @@ internal static class Normalize
case long l:
return JsonValue.Create(l);
case double d:
- if (d == 0.0 && double.IsNegative(d)) return JsonValue.Create(0.0);
+ if (BitConverter.DoubleToInt64Bits(d) == BitConverter.DoubleToInt64Bits(-0.0)) return JsonValue.Create(0.0);
if (!double.IsFinite(d)) return null;
return JsonValue.Create(d);
case float f:
- if (f == 0.0f && float.IsNegative(f)) return JsonValue.Create(0.0f);
+ if (BitConverter.SingleToInt32Bits(f) == BitConverter.SingleToInt32Bits(-0.0f)) return JsonValue.Create(0.0f);
if (!float.IsFinite(f)) return null;
return JsonValue.Create(f);
case decimal dec:
From d1334adbe7f5c677bcff94be90bb3bf7b2ba223a Mon Sep 17 00:00:00 2001
From: token <239573049@qq.com>
Date: Tue, 18 Nov 2025 03:11:38 +0800
Subject: [PATCH 8/9] Unified logic for negative zero normalization of
floating-point numbers, and a new FloatUtils utility class has been added.
Introduce the FloatUtils utility class and provide the NormalizeSignedZero
method, to uniformly handle the logic of negated zero normalization for
floating-point numbers, replacing the original repetitive implementations.
Update the relevant code in Parser.cs and Normalize.cs to enhance
readability. Add the NearlyEqual method for approximate comparison of
floating-point numbers. Delete the references to the Internal\Shared\ folder.
---
src/ToonFormat/Internal/Decode/Parser.cs | 4 +-
src/ToonFormat/Internal/Encode/Normalize.cs | 36 +++++++++--------
src/ToonFormat/Internal/Shared/FloatUtils.cs | 41 ++++++++++++++++++++
src/ToonFormat/ToonFormat.csproj | 1 -
4 files changed, 62 insertions(+), 20 deletions(-)
create mode 100644 src/ToonFormat/Internal/Shared/FloatUtils.cs
diff --git a/src/ToonFormat/Internal/Decode/Parser.cs b/src/ToonFormat/Internal/Decode/Parser.cs
index 0c15630..8f48663 100644
--- a/src/ToonFormat/Internal/Decode/Parser.cs
+++ b/src/ToonFormat/Internal/Decode/Parser.cs
@@ -289,9 +289,7 @@ public static List ParseDelimitedValues(string input, char delimiter)
if (LiteralUtils.IsNumericLiteral(trimmed))
{
var parsedNumber = double.Parse(trimmed, CultureInfo.InvariantCulture);
- // Normalize negative zero to positive zero
- if (parsedNumber == 0.0 && double.IsNegativeInfinity(1.0 / parsedNumber))
- return JsonValue.Create(0.0);
+ parsedNumber = FloatUtils.NormalizeSignedZero(parsedNumber);
return JsonValue.Create(parsedNumber);
}
diff --git a/src/ToonFormat/Internal/Encode/Normalize.cs b/src/ToonFormat/Internal/Encode/Normalize.cs
index c094c6f..c354af6 100644
--- a/src/ToonFormat/Internal/Encode/Normalize.cs
+++ b/src/ToonFormat/Internal/Encode/Normalize.cs
@@ -5,6 +5,7 @@
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
+using ToonFormat.Internal.Shared;
namespace ToonFormat.Internal.Encode
{
@@ -34,24 +35,23 @@ internal static class Normalize
if (value is bool b)
return JsonValue.Create(b);
- // Numbers: canonicalize -0 to 0, handle NaN and Infinity
+ // Numbers: canonicalize -0 to +0, handle NaN and Infinity
if (value is double d)
{
- // Detect -0 using Object.Equals or bitwise comparison
- if (d == 0.0 && double.IsNegative(d))
- return JsonValue.Create(0.0);
- if (!double.IsFinite(d))
+ // Canonicalize signed zero via FloatUtils
+ var dn = FloatUtils.NormalizeSignedZero(d);
+ if (!double.IsFinite(dn))
return null;
- return JsonValue.Create(d);
+ return JsonValue.Create(dn);
}
if (value is float f)
{
- if (f == 0.0f && float.IsNegative(f))
- return JsonValue.Create(0.0f);
- if (!float.IsFinite(f))
+ // Canonicalize signed zero via FloatUtils
+ var fn = FloatUtils.NormalizeSignedZero(f);
+ if (!float.IsFinite(fn))
return null;
- return JsonValue.Create(f);
+ return JsonValue.Create(fn);
}
// Other numeric types
@@ -137,13 +137,17 @@ internal static class Normalize
case long l:
return JsonValue.Create(l);
case double d:
- if (d == 0.0 && double.IsNegative(d)) return JsonValue.Create(0.0);
- if (!double.IsFinite(d)) return null;
- return JsonValue.Create(d);
+ {
+ var dn = FloatUtils.NormalizeSignedZero(d);
+ if (!double.IsFinite(dn)) return null;
+ return JsonValue.Create(dn);
+ }
case float f:
- if (f == 0.0f && float.IsNegative(f)) return JsonValue.Create(0.0f);
- if (!float.IsFinite(f)) return null;
- return JsonValue.Create(f);
+ {
+ var fn = FloatUtils.NormalizeSignedZero(f);
+ if (!float.IsFinite(fn)) return null;
+ return JsonValue.Create(fn);
+ }
case decimal dec:
return JsonValue.Create(dec);
case byte by:
diff --git a/src/ToonFormat/Internal/Shared/FloatUtils.cs b/src/ToonFormat/Internal/Shared/FloatUtils.cs
new file mode 100644
index 0000000..febd5e1
--- /dev/null
+++ b/src/ToonFormat/Internal/Shared/FloatUtils.cs
@@ -0,0 +1,41 @@
+using System;
+
+namespace ToonFormat.Internal.Shared
+{
+ internal static class FloatUtils
+ {
+ ///
+ /// Tolerance comparison applicable to general business: Taking into account both absolute error and relative error simultaneously
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool NearlyEqual(double a, double b, double absEps = 1e-12, double relEps = 1e-9)
+ {
+ if (double.IsNaN(a) && double.IsNaN(b)) return true; // ÒµÎñÉϳ£ÐèÒªÊÓ NaN == NaN
+ if (double.IsInfinity(a) || double.IsInfinity(b)) return a.Equals(b);
+ if (a == b) return true; // ¸²¸Ç 0.0 == -0.0¡¢ÍêÈ«ÏàµÈ
+
+ var diff = Math.Abs(a - b);
+ var scale = Math.Max(Math.Abs(a), Math.Abs(b));
+ if (scale == 0) return diff <= absEps; // Á½Õß¶¼½Ó½ü 0
+ return diff <= Math.Max(absEps, relEps * scale);
+ }
+
+ ///
+ /// Explicitly change -0.0 to +0.0 to avoid exposing the sign difference in subsequent operations such as 1.0/x.
+ ///
+ ///
+ ///
+ public static double NormalizeSignedZero(double v) =>
+ BitConverter.DoubleToInt64Bits(v) == BitConverter.DoubleToInt64Bits(-0.0) ? 0.0 : v;
+
+ ///
+ /// Explicitly change -0.0f to +0.0f for float values.
+ ///
+ public static float NormalizeSignedZero(float v) =>
+ BitConverter.SingleToInt32Bits(v) == BitConverter.SingleToInt32Bits(-0.0f) ? 0.0f : v;
+ }
+}
\ No newline at end of file
diff --git a/src/ToonFormat/ToonFormat.csproj b/src/ToonFormat/ToonFormat.csproj
index c7b5e62..ab08f50 100644
--- a/src/ToonFormat/ToonFormat.csproj
+++ b/src/ToonFormat/ToonFormat.csproj
@@ -28,7 +28,6 @@
-
From 142e6ac7813e8820c13e950d1b9f928417f41041 Mon Sep 17 00:00:00 2001
From: token <61819790+239573049@users.noreply.github.com>
Date: Tue, 16 Dec 2025 15:15:40 +0800
Subject: [PATCH 9/9] Add TOON format encoding and decoding functions, update
the project structure, and optimize the code implementation.
---
ToonFormat.slnx | 13 ++
src/ToonFormat/Constants.cs | 2 +-
src/ToonFormat/Internal/Decode/Scanner.cs | 9 +-
src/ToonFormat/Internal/Encode/LineWriter.cs | 38 ++--
src/ToonFormat/Internal/Encode/Normalize.cs | 6 +-
src/ToonFormat/Internal/Encode/Primitives.cs | 52 +++--
src/ToonFormat/ToonDecoder.cs | 48 +++++
src/ToonFormat/ToonFormat.csproj | 2 +-
src/ToonFormat/ToonSerializer.cs | 199 +++++++++++++++++++
9 files changed, 324 insertions(+), 45 deletions(-)
create mode 100644 ToonFormat.slnx
create mode 100644 src/ToonFormat/ToonSerializer.cs
diff --git a/ToonFormat.slnx b/ToonFormat.slnx
new file mode 100644
index 0000000..4dd3dcb
--- /dev/null
+++ b/ToonFormat.slnx
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ToonFormat/Constants.cs b/src/ToonFormat/Constants.cs
index 50dfe73..fea6d36 100644
--- a/src/ToonFormat/Constants.cs
+++ b/src/ToonFormat/Constants.cs
@@ -77,7 +77,7 @@ public static bool IsStructural(char c)
/// TOON's unified options configuration, styled to align with System.Text.Json. Used to control indentation,
/// delimiters, strict mode, length markers, and underlying JSON behavior.
///
- public enum ToonDelimiter
+ public enum ToonDelimiter : byte
{
/// Comma ,
COMMA,
diff --git a/src/ToonFormat/Internal/Decode/Scanner.cs b/src/ToonFormat/Internal/Decode/Scanner.cs
index 2fa9923..a384f24 100644
--- a/src/ToonFormat/Internal/Decode/Scanner.cs
+++ b/src/ToonFormat/Internal/Decode/Scanner.cs
@@ -1,7 +1,6 @@
#nullable enable
using System;
using System.Collections.Generic;
-using System.Linq;
namespace ToonFormat.Internal.Decode
{
@@ -10,7 +9,6 @@ namespace ToonFormat.Internal.Decode
///
internal class ParsedLine
{
- public string Raw { get; set; } = string.Empty;
public int Indent { get; set; }
public string Content { get; set; } = string.Empty;
public int Depth { get; set; }
@@ -127,7 +125,7 @@ public static ScanResult ToParsedLines(string source, int indentSize, bool stric
while (!span.IsEmpty)
{
lineNumber++;
- // ÕÒµ½ÕâÒ»ÐеĽáÊøÎ»ÖÃ
+ // �ҵ���һ�еĽ���λ��
int newlineIdx = span.IndexOf('\n');
ReadOnlySpan lineSpan;
if (newlineIdx >= 0)
@@ -140,12 +138,12 @@ public static ScanResult ToParsedLines(string source, int indentSize, bool stric
lineSpan = span;
span = ReadOnlySpan.Empty;
}
- // È¥µô½áβµÄ»»ÐзûºÍ»Ø³µ
+ // ȥ����β�Ļ��з��ͻس�
if (!lineSpan.IsEmpty && lineSpan[lineSpan.Length - 1] == '\r')
{
lineSpan = lineSpan.Slice(0, lineSpan.Length - 1);
}
- // ¼ÆËãËõ½ø
+ // ��������
int indent = 0;
while (indent < lineSpan.Length && lineSpan[indent] == Constants.SPACE)
{
@@ -188,7 +186,6 @@ public static ScanResult ToParsedLines(string source, int indentSize, bool stric
}
parsed.Add(new ParsedLine
{
- Raw = new string(lineSpan),
Indent = indent,
Content = new string(contentSpan),
Depth = lineDepth,
diff --git a/src/ToonFormat/Internal/Encode/LineWriter.cs b/src/ToonFormat/Internal/Encode/LineWriter.cs
index fb3409f..1fe76cb 100644
--- a/src/ToonFormat/Internal/Encode/LineWriter.cs
+++ b/src/ToonFormat/Internal/Encode/LineWriter.cs
@@ -10,8 +10,10 @@ namespace ToonFormat.Internal.Encode
///
internal class LineWriter
{
- private readonly List _lines = new();
- private readonly string _indentationString;
+ private readonly StringBuilder _builder = new();
+ private readonly string _indentationUnit;
+ private readonly List _indentCache = new() { string.Empty };
+ private bool _hasAnyLine;
///
/// Creates a new LineWriter with the specified indentation size.
@@ -19,7 +21,7 @@ internal class LineWriter
/// Number of spaces per indentation level.
public LineWriter(int indentSize)
{
- _indentationString = new string(' ', indentSize);
+ _indentationUnit = new string(' ', indentSize);
}
///
@@ -29,8 +31,17 @@ public LineWriter(int indentSize)
/// The content of the line.
public void Push(int depth, string content)
{
- var indent = RepeatString(_indentationString, depth);
- _lines.Add(indent + content);
+ if (_hasAnyLine)
+ {
+ _builder.Append('\n');
+ }
+ else
+ {
+ _hasAnyLine = true;
+ }
+
+ _builder.Append(GetIndent(depth));
+ _builder.Append(content);
}
///
@@ -48,23 +59,20 @@ public void PushListItem(int depth, string content)
///
public override string ToString()
{
- return string.Join("\n", _lines);
+ return _builder.ToString();
}
- ///
- /// Helper method to repeat a string n times.
- ///
- private static string RepeatString(string str, int count)
+ private string GetIndent(int depth)
{
- if (count <= 0)
+ if (depth <= 0)
return string.Empty;
- var sb = new StringBuilder(str.Length * count);
- for (int i = 0; i < count; i++)
+ while (_indentCache.Count <= depth)
{
- sb.Append(str);
+ _indentCache.Add(string.Concat(_indentCache[^1], _indentationUnit));
}
- return sb.ToString();
+
+ return _indentCache[depth];
}
}
}
diff --git a/src/ToonFormat/Internal/Encode/Normalize.cs b/src/ToonFormat/Internal/Encode/Normalize.cs
index acbbb1b..72aef13 100644
--- a/src/ToonFormat/Internal/Encode/Normalize.cs
+++ b/src/ToonFormat/Internal/Encode/Normalize.cs
@@ -102,8 +102,10 @@ internal static class Normalize
var type = value.GetType();
var properties = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
- foreach (var prop in properties.Where(prop => prop.CanRead))
+ foreach (var prop in properties)
{
+ if (!prop.CanRead)
+ continue;
var propValue = prop.GetValue(value);
jsonObject[prop.Name] = NormalizeValue(propValue);
}
@@ -211,7 +213,7 @@ internal static class Normalize
///
/// Determines if a value is a plain object (not a primitive, collection, or special type).
///
- private static bool IsPlainObject(object value)
+ private static bool IsPlainObject(object? value)
{
if (value == null)
return false;
diff --git a/src/ToonFormat/Internal/Encode/Primitives.cs b/src/ToonFormat/Internal/Encode/Primitives.cs
index ce666bd..7153cb3 100644
--- a/src/ToonFormat/Internal/Encode/Primitives.cs
+++ b/src/ToonFormat/Internal/Encode/Primitives.cs
@@ -1,7 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
-using System.Linq;
+using System.Text;
using System.Text.Json.Nodes;
using ToonFormat.Internal.Shared;
@@ -93,8 +93,17 @@ public static string EncodeKey(string key)
///
public static string EncodeAndJoinPrimitives(IEnumerable values, char delimiter = Constants.COMMA)
{
- var encoded = values.Select(v => EncodePrimitive(v, delimiter));
- return string.Join(delimiter.ToString(), encoded);
+ var sb = new StringBuilder();
+ bool first = true;
+ foreach (var value in values)
+ {
+ if (!first)
+ sb.Append(delimiter);
+ first = false;
+
+ sb.Append(EncodePrimitive(value, delimiter));
+ }
+ return sb.ToString();
}
// #endregion
@@ -116,33 +125,36 @@ public static string FormatHeader(
bool lengthMarker = false)
{
var delimiterChar = delimiter ?? Constants.DEFAULT_DELIMITER_CHAR;
- var header = string.Empty;
+ var sb = new StringBuilder();
// Add key if present
if (!string.IsNullOrEmpty(key))
- {
- header += EncodeKey(key);
- }
+ sb.Append(EncodeKey(key));
// Add array length with optional marker and delimiter
- var marker = lengthMarker ? Constants.HASH.ToString() : string.Empty;
- var delimiterSuffix = delimiterChar != Constants.DEFAULT_DELIMITER_CHAR
- ? delimiterChar.ToString()
- : string.Empty;
-
- header += $"{Constants.OPEN_BRACKET}{marker}{length}{delimiterSuffix}{Constants.CLOSE_BRACKET}";
+ sb.Append(Constants.OPEN_BRACKET);
+ if (lengthMarker)
+ sb.Append(Constants.HASH);
+ sb.Append(length);
+ if (delimiterChar != Constants.DEFAULT_DELIMITER_CHAR)
+ sb.Append(delimiterChar);
+ sb.Append(Constants.CLOSE_BRACKET);
// Add field names for tabular format
- if (fields != null && fields.Count > 0)
+ if (fields is { Count: > 0 })
{
- var quotedFields = fields.Select(EncodeKey);
- var fieldsStr = string.Join(delimiterChar.ToString(), quotedFields);
- header += $"{Constants.OPEN_BRACE}{fieldsStr}{Constants.CLOSE_BRACE}";
+ sb.Append(Constants.OPEN_BRACE);
+ for (int i = 0; i < fields.Count; i++)
+ {
+ if (i > 0)
+ sb.Append(delimiterChar);
+ sb.Append(EncodeKey(fields[i]));
+ }
+ sb.Append(Constants.CLOSE_BRACE);
}
- header += Constants.COLON;
-
- return header;
+ sb.Append(Constants.COLON);
+ return sb.ToString();
}
// #endregion
diff --git a/src/ToonFormat/ToonDecoder.cs b/src/ToonFormat/ToonDecoder.cs
index 41a6e29..da7b6fa 100644
--- a/src/ToonFormat/ToonDecoder.cs
+++ b/src/ToonFormat/ToonDecoder.cs
@@ -97,6 +97,54 @@ public static class ToonDecoder
return JsonSerializer.Deserialize(json);
}
+ ///
+ /// Decodes TOON data from a UTF-8 byte span into a JsonNode with default options.
+ ///
+ /// UTF-8 encoded TOON text.
+ public static JsonNode? Decode(ReadOnlySpan utf8Bytes)
+ {
+ return Decode(utf8Bytes, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a UTF-8 byte span into a JsonNode with custom options.
+ ///
+ /// UTF-8 encoded TOON text.
+ /// Decoding options to customize parsing behavior.
+ public static JsonNode? Decode(ReadOnlySpan utf8Bytes, ToonDecodeOptions? options)
+ {
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ var text = Encoding.UTF8.GetString(utf8Bytes);
+ return Decode(text, options);
+ }
+
+ ///
+ /// Decodes TOON data from a UTF-8 byte span into the specified type with default options.
+ ///
+ /// Target type to deserialize into.
+ /// UTF-8 encoded TOON text.
+ public static T? Decode(ReadOnlySpan utf8Bytes)
+ {
+ return Decode(utf8Bytes, new ToonDecodeOptions());
+ }
+
+ ///
+ /// Decodes TOON data from a UTF-8 byte span into the specified type with custom options.
+ ///
+ /// Target type to deserialize into.
+ /// UTF-8 encoded TOON text.
+ /// Decoding options to customize parsing behavior.
+ public static T? Decode(ReadOnlySpan utf8Bytes, ToonDecodeOptions? options)
+ {
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ var text = Encoding.UTF8.GetString(utf8Bytes);
+ return Decode(text, options);
+ }
+
///
/// Decodes TOON data from a UTF-8 byte array into a JsonNode with default options.
///
diff --git a/src/ToonFormat/ToonFormat.csproj b/src/ToonFormat/ToonFormat.csproj
index ab08f50..004ecbd 100644
--- a/src/ToonFormat/ToonFormat.csproj
+++ b/src/ToonFormat/ToonFormat.csproj
@@ -1,7 +1,7 @@

- net8.0;net9.0
+ net8.0;net9.0;net10.0
enable
enable
latest
diff --git a/src/ToonFormat/ToonSerializer.cs b/src/ToonFormat/ToonSerializer.cs
new file mode 100644
index 0000000..ddeb552
--- /dev/null
+++ b/src/ToonFormat/ToonSerializer.cs
@@ -0,0 +1,199 @@
+using System;
+using System.IO;
+using System.Text.Json.Nodes;
+using Toon.Format;
+
+namespace ToonFormat;
+
+///
+/// Provides a concise API similar to System.Text.Json.JsonSerializer for converting between TOON format and objects/JSON.
+///
+public static class ToonSerializer
+{
+ #region Serialize (Object to TOON)
+
+ ///
+ /// Serializes the specified value to a TOON-formatted string.
+ ///
+ /// The type of the value to serialize.
+ /// The value to serialize.
+ /// A TOON-formatted string representation of the value.
+ public static string Serialize(T value)
+ {
+ return ToonEncoder.Encode(value);
+ }
+
+ ///
+ /// Serializes the specified value to a TOON-formatted string with custom options.
+ ///
+ /// The type of the value to serialize.
+ /// The value to serialize.
+ /// Options to control serialization behavior.
+ /// A TOON-formatted string representation of the value.
+ public static string Serialize(T value, ToonEncodeOptions? options)
+ {
+ return ToonEncoder.Encode(value, options ?? new ToonEncodeOptions());
+ }
+
+ ///
+ /// Serializes the specified value to a UTF-8 encoded TOON byte array.
+ ///
+ /// The type of the value to serialize.
+ /// The value to serialize.
+ /// A UTF-8 encoded byte array containing the TOON representation.
+ public static byte[] SerializeToUtf8Bytes(T value)
+ {
+ return ToonEncoder.EncodeToBytes(value);
+ }
+
+ ///
+ /// Serializes the specified value to a UTF-8 encoded TOON byte array with custom options.
+ ///
+ /// The type of the value to serialize.
+ /// The value to serialize.
+ /// Options to control serialization behavior.
+ /// A UTF-8 encoded byte array containing the TOON representation.
+ public static byte[] SerializeToUtf8Bytes(T value, ToonEncodeOptions? options)
+ {
+ return ToonEncoder.EncodeToBytes(value, options ?? new ToonEncodeOptions());
+ }
+
+ ///
+ /// Serializes the specified value and writes it to the specified stream.
+ ///
+ /// The type of the value to serialize.
+ /// The stream to write the TOON representation to.
+ /// The value to serialize.
+ public static void Serialize(Stream stream, T value)
+ {
+ ToonEncoder.EncodeToStream(value, stream);
+ }
+
+ ///
+ /// Serializes the specified value and writes it to the specified stream with custom options.
+ ///
+ /// The type of the value to serialize.
+ /// The stream to write the TOON representation to.
+ /// The value to serialize.
+ /// Options to control serialization behavior.
+ public static void Serialize(Stream stream, T value, ToonEncodeOptions? options)
+ {
+ ToonEncoder.EncodeToStream(value, stream, options ?? new ToonEncodeOptions());
+ }
+
+ #endregion
+
+ #region Deserialize (TOON to Object)
+
+ ///
+ /// Deserializes the TOON-formatted string to the specified type.
+ ///
+ /// The type to deserialize to.
+ /// The TOON-formatted string to deserialize.
+ /// The deserialized value of type T.
+ /// Thrown when toon is null.
+ /// Thrown when the TOON format is invalid.
+ public static T? Deserialize(string toon)
+ {
+ return ToonDecoder.Decode(toon);
+ }
+
+ ///
+ /// Deserializes the TOON-formatted string to the specified type with custom options.
+ ///
+ /// The type to deserialize to.
+ /// The TOON-formatted string to deserialize.
+ /// Options to control deserialization behavior.
+ /// The deserialized value of type T.
+ /// Thrown when toon or options is null.
+ /// Thrown when the TOON format is invalid.
+ public static T? Deserialize(string toon, ToonDecodeOptions? options)
+ {
+ return ToonDecoder.Decode(toon, options ?? new ToonDecodeOptions());
+ }
+
+ ///
+ /// Deserializes the UTF-8 encoded TOON byte array to the specified type.
+ ///
+ /// The type to deserialize to.
+ /// The UTF-8 encoded TOON byte array to deserialize.
+ /// The deserialized value of type T.
+ /// Thrown when utf8Toon is null.
+ /// Thrown when the TOON format is invalid.
+ public static T? Deserialize(ReadOnlySpan utf8Toon)
+ {
+ return ToonDecoder.Decode(utf8Toon);
+ }
+
+ ///
+ /// Deserializes the UTF-8 encoded TOON byte array to the specified type with custom options.
+ ///
+ /// The type to deserialize to.
+ /// The UTF-8 encoded TOON byte array to deserialize.
+ /// Options to control deserialization behavior.
+ /// The deserialized value of type T.
+ /// Thrown when options is null.
+ /// Thrown when the TOON format is invalid.
+ public static T? Deserialize(ReadOnlySpan utf8Toon, ToonDecodeOptions? options)
+ {
+ return ToonDecoder.Decode(utf8Toon, options ?? new ToonDecodeOptions());
+ }
+
+ ///
+ /// Deserializes the TOON data from the specified stream to the specified type.
+ ///
+ /// The type to deserialize to.
+ /// The stream containing TOON data to deserialize.
+ /// The deserialized value of type T.
+ /// Thrown when stream is null.
+ /// Thrown when the TOON format is invalid.
+ public static T? Deserialize(Stream stream)
+ {
+ return ToonDecoder.Decode(stream);
+ }
+
+ ///
+ /// Deserializes the TOON data from the specified stream to the specified type with custom options.
+ ///
+ /// The type to deserialize to.
+ /// The stream containing TOON data to deserialize.
+ /// Options to control deserialization behavior.
+ /// The deserialized value of type T.
+ /// Thrown when stream or options is null.
+ /// Thrown when the TOON format is invalid.
+ public static T? Deserialize(Stream stream, ToonDecodeOptions? options)
+ {
+ return ToonDecoder.Decode(stream, options ?? new ToonDecodeOptions());
+ }
+
+ #endregion
+
+ #region JsonNode Conversion
+
+ ///
+ /// Deserializes the TOON-formatted string to a JsonNode.
+ ///
+ /// The TOON-formatted string to deserialize.
+ /// The deserialized JsonNode.
+ /// Thrown when toon is null.
+ /// Thrown when the TOON format is invalid.
+ public static JsonNode? DeserializeToJsonNode(string toon)
+ {
+ return ToonDecoder.Decode(toon);
+ }
+
+ ///
+ /// Deserializes the TOON-formatted string to a JsonNode with custom options.
+ ///
+ /// The TOON-formatted string to deserialize.
+ /// Options to control deserialization behavior.
+ /// The deserialized JsonNode.
+ /// Thrown when toon or options is null.
+ /// Thrown when the TOON format is invalid.
+ public static JsonNode? DeserializeToJsonNode(string toon, ToonDecodeOptions? options)
+ {
+ return ToonDecoder.Decode(toon, options ?? new ToonDecodeOptions());
+ }
+
+ #endregion
+}
\ No newline at end of file