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