Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ecef101
Implemented Specs v3.0
krlosflipdev Nov 27, 2025
16d35fb
- Removed `IsIdentifierSegment` and `IdentifierPattern` from `PathExp…
krlosflipdev Nov 28, 2025
64a00e7
Update README.md
krlosflipdev Nov 28, 2025
1e079c2
Fixes
krlosflipdev Nov 28, 2025
1d4f35a
Revert "Fixes"
krlosflipdev Nov 28, 2025
db0db15
Fixes
krlosflipdev Nov 28, 2025
898b54c
Fixed tests
krlosflipdev Nov 28, 2025
f2f5f48
Updated tests
krlosflipdev Nov 28, 2025
4651a38
Fixed tests on windows
krlosflipdev Nov 28, 2025
843c3d9
feat: Ensure numeric values are fully represented
ghost1face Dec 24, 2025
7bb5305
feat: Remove LengthMarker references
ghost1face Dec 24, 2025
5358db6
chore: Remove unused converters
ghost1face Dec 24, 2025
b01d468
chore: Test cleanup
ghost1face Dec 24, 2025
b1a3501
chore: Change test project structure, manual tests/specs are no longe…
ghost1face Dec 24, 2025
32a87d9
feat: Test to validate issue #7 is resolved
ghost1face Dec 24, 2025
b21f376
chore: Default namespaces to Toon.Format
ghost1face Dec 24, 2025
e3fa6ad
chore: Regenerate test file for appropriate line endings
ghost1face Dec 24, 2025
9c709b3
Update README.md
ghost1face Dec 24, 2025
1b1ba37
Update README.md
ghost1face Dec 24, 2025
3a5ef0f
chore: Code cleanup based on code review
ghost1face Dec 24, 2025
38198e8
Merge remote-tracking branch 'origin/full-spec-2' into full-spec-2
ghost1face Dec 24, 2025
61979dd
fix: Remove unnecessary .ToString invocations
ghost1face Dec 24, 2025
5511953
fix: Use AsSpan to minimize string allocations
ghost1face Dec 24, 2025
f837253
feat: Use Span<char> where possible to minimize allocations
ghost1face Dec 24, 2025
a1087be
chore: Resolve build warning
ghost1face Dec 24, 2025
34914fc
fix: Use AsSpan to minimize string allocations
ghost1face Dec 24, 2025
9474cdf
Merge branch 'main' into string-allocations
ghost1face Jan 14, 2026
74118e7
fix: Use partial modifier removed from bad merge
ghost1face Jan 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions src/ToonFormat/Internal/Decode/Decoders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,17 @@ internal static class Decoders

private static bool IsKeyValueLine(ParsedLine line)
{
var content = line.Content;
var content = line.Content.AsSpan();
// Look for unquoted colon or quoted key followed by colon
if (content.StartsWith("\""))
if (content.StartsWith(Constants.DOUBLE_QUOTE))
{
// 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);
return content.Slice(closingQuoteIndex + 1).Contains(Constants.COLON);
}
else
{
Expand Down Expand Up @@ -129,10 +129,9 @@ private class KeyValueDecodeResult
/// to the hyphen line. This method adjusts the effective depth accordingly.
/// </summary>
private static KeyValueDecodeResult DecodeKeyValue(
string content,
ReadOnlySpan<char> content,
LineCursor cursor,
int baseDepth,

ResolvedDecodeOptions options,
bool isListItemFirstField = false)
{
Expand All @@ -159,10 +158,10 @@ private static KeyValueDecodeResult DecodeKeyValue(

// Regular key-value pair
var keyResult = Parser.ParseKeyToken(content, 0);
var rest = content.Substring(keyResult.End).Trim();
var rest = content.Slice(keyResult.End).Trim();

// No value after colon - expect nested object or empty
if (string.IsNullOrEmpty(rest))
if (rest.IsEmpty)
{
var nextLine = cursor.Peek();
if (nextLine != null && nextLine.Depth > baseDepth)
Expand Down Expand Up @@ -393,24 +392,25 @@ private static List<JsonObject> DecodeTabularArray(
}

// Check for list item (with or without space after hyphen)
string afterHyphen;
ReadOnlySpan<char> afterHyphen;

// Empty list item should be an empty object
if (line.Content == "-")
{
return new JsonObject();
}
else if (line.Content.StartsWith(Constants.LIST_ITEM_PREFIX))

if (line.Content.StartsWith(Constants.LIST_ITEM_PREFIX))
{
afterHyphen = line.Content.Substring(Constants.LIST_ITEM_PREFIX.Length);
afterHyphen = line.Content.AsSpan(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))
if (afterHyphen.IsEmpty)
{
return new JsonObject();
}
Expand Down Expand Up @@ -446,7 +446,7 @@ private static JsonObject DecodeObjectFromListItem(
int baseDepth,
ResolvedDecodeOptions options)
{
var afterHyphen = firstLine.Content.Substring(Constants.LIST_ITEM_PREFIX.Length);
var afterHyphen = firstLine.Content.AsSpan(Constants.LIST_ITEM_PREFIX.Length);
var firstField = DecodeKeyValue(afterHyphen, cursor, baseDepth, options, isListItemFirstField: true);

var obj = new JsonObject { [firstField.Key] = firstField.Value };
Expand Down
85 changes: 48 additions & 37 deletions src/ToonFormat/Internal/Decode/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Linq;
using System.Text.Json.Nodes;
using Toon.Format.Internal.Shared;
using Toon.Format.Internal;

namespace Toon.Format.Internal.Decode
{
Expand Down Expand Up @@ -39,7 +40,7 @@ internal static class Parser
/// <summary>
/// Parses an array header line like "key[3]:" or "users[#2,]{name,age}:".
/// </summary>
public static ArrayHeaderParseResult? ParseArrayHeaderLine(string content, char defaultDelimiter)
public static ArrayHeaderParseResult? ParseArrayHeaderLine(ReadOnlySpan<char> content, char defaultDelimiter)
{
var trimmed = content.TrimStart();

Expand All @@ -53,8 +54,8 @@ internal static class Parser
if (closingQuoteIndex == -1)
return null;

var afterQuote = trimmed.Substring(closingQuoteIndex + 1);
if (!afterQuote.StartsWith(Constants.OPEN_BRACKET.ToString()))
var afterQuote = trimmed.Slice(closingQuoteIndex + 1);
if (!afterQuote.StartsWith(Constants.OPEN_BRACKET))
return null;

// Calculate position in original content and find bracket after the quoted key
Expand Down Expand Up @@ -99,14 +100,14 @@ internal static class Parser
string? key = null;
if (bracketStart > 0)
{
var rawKey = content.Substring(0, bracketStart).Trim();
key = rawKey.StartsWith(Constants.DOUBLE_QUOTE.ToString())
var rawKey = content.Slice(0, bracketStart).Trim();
key = rawKey.StartsWith(Constants.DOUBLE_QUOTE)
? ParseStringLiteral(rawKey)
: rawKey;
: rawKey.ToString();
}

var afterColon = content.Substring(colonIndex + 1).Trim();
var bracketContent = content.Substring(bracketStart + 1, bracketEnd - bracketStart - 1);
var afterColon = content.Slice(colonIndex + 1).Trim();
var bracketContent = content.Slice(bracketStart + 1, bracketEnd - bracketStart - 1);

// Try to parse bracket segment
BracketSegmentResult parsedBracket;
Expand All @@ -126,7 +127,7 @@ internal static class Parser
var foundBraceEnd = content.IndexOf(Constants.CLOSE_BRACE, braceStart);
if (foundBraceEnd != -1 && foundBraceEnd < colonIndex)
{
var fieldsContent = content.Substring(braceStart + 1, foundBraceEnd - braceStart - 1);
var fieldsContent = content.Slice(braceStart + 1, foundBraceEnd - braceStart - 1);
fields = ParseDelimitedValues(fieldsContent, parsedBracket.Delimiter)
.Select(field => ParseStringLiteral(field.Trim()))
.ToList();
Expand All @@ -142,7 +143,7 @@ internal static class Parser
Delimiter = parsedBracket.Delimiter,
Fields = fields,
},
InlineValues = string.IsNullOrEmpty(afterColon) ? null : afterColon
InlineValues = afterColon.IsEmpty ? null : afterColon.ToString()
};
}

Expand All @@ -152,21 +153,21 @@ private class BracketSegmentResult
public char Delimiter { get; set; }
}

private static BracketSegmentResult ParseBracketSegment(string seg, char defaultDelimiter)
private static BracketSegmentResult ParseBracketSegment(ReadOnlySpan<char> seg, char defaultDelimiter)
{
var content = seg;

// Check for delimiter suffix
char delimiter = defaultDelimiter;
if (content.EndsWith(Constants.TAB.ToString()))
if (content.EndsWith(Constants.TAB))
{
delimiter = Constants.TAB;
content = content.Substring(0, content.Length - 1);
content = content.Slice(0, content.Length - 1);
}
else if (content.EndsWith(Constants.PIPE.ToString()))
else if (content.EndsWith(Constants.PIPE))
{
delimiter = Constants.PIPE;
content = content.Substring(0, content.Length - 1);
content = content.Slice(0, content.Length - 1);
}

if (!int.TryParse(content, out var length))
Expand All @@ -188,7 +189,7 @@ private static BracketSegmentResult ParseBracketSegment(string seg, char default
/// <summary>
/// Parses a delimiter-separated string into individual values, respecting quotes.
/// </summary>
public static List<string> ParseDelimitedValues(string input, char delimiter)
public static List<string> ParseDelimitedValues(ReadOnlySpan<char> input, char delimiter)
{
var values = new List<string>(16); // pre-allocate for performance
var current = new System.Text.StringBuilder(input.Length);
Expand Down Expand Up @@ -247,28 +248,28 @@ public static List<string> ParseDelimitedValues(string input, char delimiter)
/// <summary>
/// Parses a primitive token (null, boolean, number, or string).
/// </summary>
public static JsonNode? ParsePrimitiveToken(string token)
public static JsonNode? ParsePrimitiveToken(ReadOnlySpan<char> token)
{
var trimmed = token.Trim();

// Empty token
if (string.IsNullOrEmpty(trimmed))
if (trimmed.IsEmpty)
return JsonValue.Create(string.Empty);

// Quoted string (if starts with quote, it MUST be properly quoted)
if (trimmed.StartsWith(Constants.DOUBLE_QUOTE.ToString()))
if (trimmed.StartsWith(Constants.DOUBLE_QUOTE))
{
return JsonValue.Create(ParseStringLiteral(trimmed));
}

// Boolean or null literals
if (LiteralUtils.IsBooleanOrNullLiteral(trimmed))
{
if (trimmed == Constants.TRUE_LITERAL)
if (trimmed.Equals(Constants.TRUE_LITERAL, StringComparison.Ordinal))
return JsonValue.Create(true);
if (trimmed == Constants.FALSE_LITERAL)
if (trimmed.Equals( Constants.FALSE_LITERAL, StringComparison.Ordinal))
return JsonValue.Create(false);
if (trimmed == Constants.NULL_LITERAL)
if (trimmed.Equals(Constants.NULL_LITERAL, StringComparison.Ordinal))
return null;
}

Expand All @@ -286,17 +287,17 @@ public static List<string> ParseDelimitedValues(string input, char delimiter)
}

// Unquoted string
return JsonValue.Create(trimmed);
return JsonValue.Create(trimmed.ToString());
}

/// <summary>
/// Parses a string literal, handling quotes and escape sequences.
/// </summary>
public static string ParseStringLiteral(string token)
public static string ParseStringLiteral(ReadOnlySpan<char> token)
{
var trimmedToken = token.Trim();

if (trimmedToken.StartsWith(Constants.DOUBLE_QUOTE.ToString()))
if (trimmedToken.StartsWith(Constants.DOUBLE_QUOTE))
{
// Find the closing quote, accounting for escaped quotes
var closingQuoteIndex = StringUtils.FindClosingQuote(trimmedToken, 0);
Expand All @@ -311,11 +312,11 @@ public static string ParseStringLiteral(string token)
throw ToonFormatException.Syntax("Unexpected characters after closing quote");
}

var content = trimmedToken.Substring(1, closingQuoteIndex - 1);
var content = trimmedToken.Slice(1, closingQuoteIndex - 1);
return StringUtils.UnescapeString(content);
}

return trimmedToken;
return trimmedToken.ToString();
}

public class KeyParseResult
Expand All @@ -325,7 +326,7 @@ public class KeyParseResult
public bool WasQuoted { get; set; }
}

public static KeyParseResult ParseUnquotedKey(string content, int start)
public static KeyParseResult ParseUnquotedKey(ReadOnlySpan<char> content, int start)
{
int end = start;
while (end < content.Length && content[end] != Constants.COLON)
Expand All @@ -339,15 +340,20 @@ public static KeyParseResult ParseUnquotedKey(string content, int start)
throw ToonFormatException.Syntax("Missing colon after key");
}

var key = content.Substring(start, end - start).Trim();
var key = content.Slice(start, end - start).Trim();

// Skip the colon
end++;

return new KeyParseResult { Key = key, End = end, WasQuoted = false };
return new KeyParseResult
{
Key = key.ToString(),
End = end,
WasQuoted = false
};
}

public static KeyParseResult ParseQuotedKey(string content, int start)
public static KeyParseResult ParseQuotedKey(ReadOnlySpan<char> content, int start)
{
// Find the closing quote, accounting for escaped quotes
var closingQuoteIndex = StringUtils.FindClosingQuote(content, start);
Expand All @@ -358,7 +364,7 @@ public static KeyParseResult ParseQuotedKey(string content, int start)
}

// Extract and unescape the key content
var keyContent = content.Substring(start + 1, closingQuoteIndex - start - 1);
var keyContent = content.Slice(start + 1, closingQuoteIndex - start - 1);
var key = StringUtils.UnescapeString(keyContent);
int end = closingQuoteIndex + 1;

Expand All @@ -370,13 +376,18 @@ public static KeyParseResult ParseQuotedKey(string content, int start)

end++;

return new KeyParseResult { Key = key, End = end, WasQuoted = true };
return new KeyParseResult
{
Key = key,
End = end,
WasQuoted = true
};
}

/// <summary>
/// Parses a key token (quoted or unquoted) and returns the key and position after colon.
/// </summary>
public static KeyParseResult ParseKeyToken(string content, int start)
public static KeyParseResult ParseKeyToken(ReadOnlySpan<char> content, int start)
{
if (content[start] == Constants.DOUBLE_QUOTE)
{
Expand All @@ -395,16 +406,16 @@ public static KeyParseResult ParseKeyToken(string content, int start)
/// <summary>
/// Checks if content after hyphen starts with an array header.
/// </summary>
public static bool IsArrayHeaderAfterHyphen(string content)
public static bool IsArrayHeaderAfterHyphen(ReadOnlySpan<char> content)
{
return content.Trim().StartsWith(Constants.OPEN_BRACKET.ToString())
return content.Trim().StartsWith(Constants.OPEN_BRACKET)
&& StringUtils.FindUnquotedChar(content, Constants.COLON) != -1;
}

/// <summary>
/// Checks if content after hyphen contains a key-value pair (has a colon).
/// </summary>
public static bool IsObjectFirstFieldAfterHyphen(string content)
public static bool IsObjectFirstFieldAfterHyphen(ReadOnlySpan<char> content)
{
return StringUtils.FindUnquotedChar(content, Constants.COLON) != -1;
}
Expand Down
21 changes: 13 additions & 8 deletions src/ToonFormat/Internal/Encode/Primitives.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public static string EncodeKey(string key)
}

var escaped = StringUtils.EscapeString(key);

return $"{Constants.DOUBLE_QUOTE}{escaped}{Constants.DOUBLE_QUOTE}";
}

Expand All @@ -140,7 +141,7 @@ public static string EncodeKey(string key)
public static string EncodeAndJoinPrimitives(IEnumerable<JsonNode?> values, char delimiter = Constants.COMMA)
{
var encoded = values.Select(v => EncodePrimitive(v, delimiter));
return string.Join(delimiter.ToString(), encoded);
return string.Join(delimiter, encoded);
}

// #endregion
Expand Down Expand Up @@ -170,17 +171,21 @@ public static string FormatHeader(
}

// Add array length with optional marker and delimiter
var delimiterSuffix = delimiterChar != Constants.DEFAULT_DELIMITER_CHAR
? delimiterChar.ToString()
: string.Empty;

header += $"{Constants.OPEN_BRACKET}{length}{delimiterSuffix}{Constants.CLOSE_BRACKET}";
if (delimiterChar != Constants.DEFAULT_DELIMITER_CHAR)
{
header += $"{Constants.OPEN_BRACKET}{length}{delimiterChar}{Constants.CLOSE_BRACKET}";
}
else
{
// default delimiter does not need to be rendered
header += $"{Constants.OPEN_BRACKET}{length}{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);
var fieldsStr = string.Join(delimiterChar, quotedFields);
header += $"{Constants.OPEN_BRACE}{fieldsStr}{Constants.CLOSE_BRACE}";
}

Expand All @@ -191,4 +196,4 @@ public static string FormatHeader(

// #endregion
}
}
}
Loading