From 81dc5cf08b6747c9cbdfb4ef0004b3e2acc13001 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 01:10:26 +0000 Subject: [PATCH 1/6] Initial plan From 881fe83acf71d99b4a2879454c61634177057fdf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 01:22:26 +0000 Subject: [PATCH 2/6] Update goon to support TOON v2 format Co-authored-by: tnfssc <29162020+tnfssc@users.noreply.github.com> --- pkg/toon/encoder.go | 465 +++++++++++++++++++++++++++++---------- pkg/toon/marshal_test.go | 28 ++- pkg/toon/parser.go | 122 +++++++--- pkg/toon/parser_utils.go | 87 +++++++- tests/test.toon | 15 +- tests/test_advanced.toon | 8 +- tests/test_parity.toon | 4 +- 7 files changed, 564 insertions(+), 165 deletions(-) diff --git a/pkg/toon/encoder.go b/pkg/toon/encoder.go index 5abb47d..39224dd 100644 --- a/pkg/toon/encoder.go +++ b/pkg/toon/encoder.go @@ -3,6 +3,7 @@ package toon import ( "fmt" "sort" + "strconv" "strings" ) @@ -20,6 +21,103 @@ func encode(value JsonValue, options EncodeOptions) (string, error) { return sb.String(), nil } +// isPrimitive checks if a value is a primitive (not object or array) +func isPrimitive(v interface{}) bool { + switch v.(type) { + case map[string]interface{}, []interface{}: + return false + default: + return true + } +} + +// isPrimitiveArray checks if all items in an array are primitives +func isPrimitiveArray(arr []interface{}) bool { + for _, item := range arr { + if !isPrimitive(item) { + return false + } + } + return true +} + +// needsQuoting checks if a string value needs to be quoted +func needsQuoting(s string) bool { + // Empty string needs quoting + if s == "" { + return true + } + // Leading or trailing whitespace + if s != strings.TrimSpace(s) { + return true + } + // Reserved literals + if s == "true" || s == "false" || s == "null" { + return true + } + // Numeric-like + if _, err := strconv.ParseFloat(s, 64); err == nil { + return true + } + // Leading zeros (like "05") + if len(s) > 1 && s[0] == '0' && s[1] >= '0' && s[1] <= '9' { + return true + } + // Contains colon, double quote, backslash + if strings.ContainsAny(s, ":\"\\") { + return true + } + // Contains brackets or braces + if strings.ContainsAny(s, "[]{}") { + return true + } + // Contains control characters + if strings.ContainsAny(s, "\n\r\t") { + return true + } + // Contains comma (delimiter) + if strings.Contains(s, ",") { + return true + } + // Starts with hyphen + if len(s) > 0 && s[0] == '-' { + return true + } + return false +} + +// quoteString quotes and escapes a string value +func quoteString(s string) string { + // Escape special characters + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "\\r") + s = strings.ReplaceAll(s, "\t", "\\t") + return "\"" + s + "\"" +} + +// encodePrimitiveValue encodes a primitive value, optionally quoting strings +func encodePrimitiveValue(v interface{}) string { + switch val := v.(type) { + case nil: + return "null" + case bool: + return fmt.Sprintf("%t", val) + case float64: + return fmt.Sprintf("%g", val) + case int: + return fmt.Sprintf("%d", val) + case string: + if needsQuoting(val) { + return quoteString(val) + } + return val + default: + return fmt.Sprintf("%v", val) + } +} + func encodeValue(sb *strings.Builder, value JsonValue, depth int, options EncodeOptions) error { switch v := value.(type) { @@ -33,8 +131,11 @@ func encodeValue(sb *strings.Builder, value JsonValue, depth int, options Encode case int: sb.WriteString(fmt.Sprintf("%d", v)) case string: - // TODO: Handle quoting if needed - sb.WriteString(v) + if needsQuoting(v) { + sb.WriteString(quoteString(v)) + } else { + sb.WriteString(v) + } case map[string]interface{}: return encodeObject(sb, v, depth, options) case []interface{}: @@ -59,41 +160,30 @@ func encodeObject(sb *strings.Builder, obj map[string]interface{}, depth int, op for i, key := range keys { val := obj[key] - // If it's the first item and we are not at root (depth > 0), we might need newline if called from parent - // But `encodeValue` is usually called for the value part. - // Actually, `encodeObject` is called when the value IS an object. - - // If we are at root (depth 0), we just list keys. - // If we are nested, we need to ensure we are on a new line? - // The caller handles the key and colon. - - // Wait, `encodeValue` is called for the VALUE. - // If the value is an object, we need to print its fields on NEW lines with indentation. - // BUT, if the object is empty, we print `{}`? Or just nothing? - // TOON doesn't use braces. Empty object is just nothing? - if i > 0 { sb.WriteString("\n") } sb.WriteString(indent) sb.WriteString(key) - sb.WriteString(": ") // Check if value is complex (object or array) or primitive switch val := val.(type) { case map[string]interface{}: - sb.WriteString("\n") - if err := encodeObject(sb, val, depth+1, options); err != nil { - return err + sb.WriteString(":") + if len(val) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, val, depth+1, options); err != nil { + return err + } } case []interface{}: - // Array handling - // Print on same line: "key: 3 |" - if err := encodeArray(sb, val, depth, options); err != nil { + // Array handling - encode header inline with key + if err := encodeArrayWithKey(sb, val, depth, options); err != nil { return err } default: + sb.WriteString(": ") if err := encodeValue(sb, val, depth, options); err != nil { return err } @@ -102,106 +192,259 @@ func encodeObject(sb *strings.Builder, obj map[string]interface{}, depth int, op return nil } -func encodeArray(sb *strings.Builder, arr []interface{}, depth int, options EncodeOptions) error { - // Use list format for now - // Header: length | - // But simple list is just items with "- " +// encodeArrayWithKey encodes an array as a value for a key (key already written, need to write header and content) +func encodeArrayWithKey(sb *strings.Builder, arr []interface{}, depth int, options EncodeOptions) error { + // TOON v2 array format: key[N]: for comma delimiter (default) + // For primitive arrays: inline format - key[N]: v1,v2,v3 + // For object arrays: list format - key[N]: then items with - prefix + + if isPrimitiveArray(arr) { + // Inline format for primitive arrays + sb.WriteString(fmt.Sprintf("[%d]:", len(arr))) + if len(arr) > 0 { + sb.WriteString(" ") + for i, item := range arr { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(encodePrimitiveValue(item)) + } + } + } else { + // List format for arrays with objects or nested arrays + sb.WriteString(fmt.Sprintf("[%d]:", len(arr))) - // We should output the header first if we want to be explicit, or just list items. - // TOON arrays usually start with a header line if they are not inline. - // e.g. "items: 3" (if key is items) - // But here we are encoding the VALUE. The key was already printed by parent. - // So we just print the array content. + indent := strings.Repeat(" ", (depth+1)*options.IndentSize) - // If we want to use the header syntax: - // The parent printed "key: ". - // We print "3 |" (length and delimiter) - // Then items. + for _, item := range arr { + sb.WriteString("\n") + sb.WriteString(indent) + sb.WriteString("- ") - // Use list format with bracket syntax: [length|] - sb.WriteString(fmt.Sprintf("[%d|]", len(arr))) + if obj, ok := item.(map[string]interface{}); ok { + // Special handling for object in list + if len(obj) == 0 { + // Empty object - just the hyphen (remove the space we added) + // Actually, per spec, empty object is just "-" on its own line + // We already wrote "- ", so we need to handle this better + // Let's trim the space we added + } else { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) - indent := strings.Repeat(" ", (depth+1)*options.IndentSize) + firstKey := keys[0] + firstVal := obj[firstKey] - for _, item := range arr { - sb.WriteString("\n") - sb.WriteString(indent) - sb.WriteString("- ") - - // If item is object, we can inline the first field or nest it - // For simplicity, let's just encode the value - // If it's an object, `encodeValue` -> `encodeObject` might try to print fields. - // We need to handle the "object in list item" case specially if we want compact syntax. - // e.g. "- name: John" - - if obj, ok := item.(map[string]interface{}); ok { - // Special handling for object in list - // Pick first key? Or just new line? - // Let's try to be compact: "- firstKey: firstVal" - // Then other keys on next lines. - - keys := make([]string, 0, len(obj)) - for k := range obj { - keys = append(keys, k) + // Write first key + sb.WriteString(firstKey) + + // Check if first val is an array (needs special handling) + if arrVal, ok := firstVal.([]interface{}); ok { + if err := encodeArrayWithKey(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := firstVal.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, firstVal, 0, options); err != nil { + return err + } + } + + // Remaining keys at depth+1 + subIndent := strings.Repeat(" ", (depth+1)*options.IndentSize) + for i := 1; i < len(keys); i++ { + k := keys[i] + v := obj[k] + sb.WriteString("\n") + sb.WriteString(subIndent) + sb.WriteString(k) + + // Check if value is array or object + if arrVal, ok := v.([]interface{}); ok { + if err := encodeArrayWithKey(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := v.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, v, 0, options); err != nil { + return err + } + } + } + } + } else if nestedArr, ok := item.([]interface{}); ok { + // Nested array as list item + // Format: - [M]: v1,v2,... for primitive arrays + // or - [M]: with list items for complex arrays + if isPrimitiveArray(nestedArr) { + sb.WriteString(fmt.Sprintf("[%d]:", len(nestedArr))) + if len(nestedArr) > 0 { + sb.WriteString(" ") + for i, nestedItem := range nestedArr { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(encodePrimitiveValue(nestedItem)) + } + } + } else { + // Complex nested array - use list format + sb.WriteString(fmt.Sprintf("[%d]:", len(nestedArr))) + nestedIndent := strings.Repeat(" ", (depth+2)*options.IndentSize) + for _, nestedItem := range nestedArr { + sb.WriteString("\n") + sb.WriteString(nestedIndent) + sb.WriteString("- ") + if err := encodeValue(sb, nestedItem, depth+2, options); err != nil { + return err + } + } + } + } else { + // Primitive item + if err := encodeValue(sb, item, 0, options); err != nil { + return err + } } - sort.Strings(keys) - - if len(keys) > 0 { - firstKey := keys[0] - sb.WriteString(firstKey) - sb.WriteString(": ") - - firstVal := obj[firstKey] - // Encode first value - // If first value is primitive, it goes on same line. - // If it's complex, it might need newline. - - // Simplified: just call encodeValue for first val - // But we need to handle subsequent keys. - - // This is getting complex for a simple encoder. - // Let's just delegate to encodeValue but we need to handle the indentation of subsequent keys. - // Actually, `encodeObject` uses `depth`. - // If we call `encodeObject` here, it will print fields. - // But we already printed "- ". - // If we pass `depth+1`, it will indent relative to list item? - - // Let's do this: - // Print first key-value manually. - // Then print rest of keys with indentation. - - // Check if first val is primitive - switch firstVal.(type) { - case map[string]interface{}, []interface{}: - sb.WriteString("\n") - encodeValue(sb, firstVal, depth+2, options) - default: - encodeValue(sb, firstVal, 0, options) // 0 depth because it's inline + } + } + return nil +} + +func encodeArray(sb *strings.Builder, arr []interface{}, depth int, options EncodeOptions) error { + // This is called when array is the root value + if isPrimitiveArray(arr) { + // Inline format for primitive arrays at root + sb.WriteString(fmt.Sprintf("[%d]:", len(arr))) + if len(arr) > 0 { + sb.WriteString(" ") + for i, item := range arr { + if i > 0 { + sb.WriteString(",") } + sb.WriteString(encodePrimitiveValue(item)) + } + } + } else { + // List format for arrays with objects + sb.WriteString(fmt.Sprintf("[%d]:", len(arr))) + + indent := strings.Repeat(" ", (depth+1)*options.IndentSize) + + for _, item := range arr { + sb.WriteString("\n") + sb.WriteString(indent) + sb.WriteString("- ") + + if obj, ok := item.(map[string]interface{}); ok { + if len(obj) == 0 { + // Empty object - remove the trailing space from "- " + } else { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + + firstKey := keys[0] + firstVal := obj[firstKey] + + sb.WriteString(firstKey) - // Remaining keys - subIndent := strings.Repeat(" ", (depth+1)*options.IndentSize) - for i := 1; i < len(keys); i++ { - k := keys[i] - v := obj[k] - sb.WriteString("\n") - sb.WriteString(subIndent) - sb.WriteString(k) - sb.WriteString(": ") - // Encode value - switch v.(type) { - case map[string]interface{}, []interface{}: + if arrVal, ok := firstVal.([]interface{}); ok { + if err := encodeArrayWithKey(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := firstVal.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, firstVal, 0, options); err != nil { + return err + } + } + + subIndent := strings.Repeat(" ", (depth+1)*options.IndentSize) + for i := 1; i < len(keys); i++ { + k := keys[i] + v := obj[k] sb.WriteString("\n") - encodeValue(sb, v, depth+2, options) - default: - encodeValue(sb, v, 0, options) + sb.WriteString(subIndent) + sb.WriteString(k) + + if arrVal, ok := v.([]interface{}); ok { + if err := encodeArrayWithKey(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := v.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, v, 0, options); err != nil { + return err + } + } } } - } - } else { - // Primitive or array - if err := encodeValue(sb, item, 0, options); err != nil { - return err + } else if nestedArr, ok := item.([]interface{}); ok { + if isPrimitiveArray(nestedArr) { + sb.WriteString(fmt.Sprintf("[%d]:", len(nestedArr))) + if len(nestedArr) > 0 { + sb.WriteString(" ") + for i, nestedItem := range nestedArr { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(encodePrimitiveValue(nestedItem)) + } + } + } else { + sb.WriteString(fmt.Sprintf("[%d]:", len(nestedArr))) + nestedIndent := strings.Repeat(" ", (depth+2)*options.IndentSize) + for _, nestedItem := range nestedArr { + sb.WriteString("\n") + sb.WriteString(nestedIndent) + sb.WriteString("- ") + if err := encodeValue(sb, nestedItem, depth+2, options); err != nil { + return err + } + } + } + } else { + if err := encodeValue(sb, item, 0, options); err != nil { + return err + } } } } diff --git a/pkg/toon/marshal_test.go b/pkg/toon/marshal_test.go index 2fe2d40..816aab3 100644 --- a/pkg/toon/marshal_test.go +++ b/pkg/toon/marshal_test.go @@ -121,9 +121,10 @@ func TestMarshalNestedStruct(t *testing.T) { } // Should contain nested structure + // Note: TOON v2 requires quoting numeric-like strings, so "123" becomes \"123\" dataStr := string(data) - if !contains(dataStr, "id: 123") { - t.Errorf("Missing 'id: 123' in output:\n%s", dataStr) + if !contains(dataStr, "id: \"123\"") { + t.Errorf("Missing 'id: \"123\"' in output:\n%s", dataStr) } if !contains(dataStr, "person:") { t.Errorf("Missing 'person:' in output:\n%s", dataStr) @@ -226,14 +227,31 @@ func TestMarshalArray(t *testing.T) { t.Fatalf("Marshal failed: %v", err) } - // Should produce a list format + // Should produce inline format with TOON v2 header [5]: dataStr := string(data) - if !contains(dataStr, "[5|]") { - t.Errorf("Missing array header '[5|]' in output:\n%s", dataStr) + if !contains(dataStr, "[5]:") { + t.Errorf("Missing array header '[5]:' in output:\n%s", dataStr) } } func TestUnmarshalArray(t *testing.T) { + // Test TOON v2 inline format + toonData := []byte(`[3]: 10,20,30`) + + var result []int + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + expected := []int{10, 20, 30} + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, got %v", expected, result) + } +} + +func TestUnmarshalArrayLegacyList(t *testing.T) { + // Test legacy list format for backward compatibility toonData := []byte(`[3|] - 10 - 20 diff --git a/pkg/toon/parser.go b/pkg/toon/parser.go index f72d382..277a931 100644 --- a/pkg/toon/parser.go +++ b/pkg/toon/parser.go @@ -27,22 +27,45 @@ func decodeValueFromLines(cursor *LineCursor, options DecodeOptions) (JsonValue, return nil, fmt.Errorf("no content to decode") } - // Check for root array - // TODO: Implement root array detection logic if needed, for now assume object or primitive - // The TS implementation checks `isArrayHeaderAfterHyphen` but that seems specific to list items? - // Actually it checks `isArrayHeaderAfterHyphen` on the first line content. - - // Check for root array - if IsArrayHeaderAfterHyphen(first.Content) { - headerInfo := ParseArrayHeaderLine(first.Content, DelimiterComma) + // Check for root array with TOON v2 format: [N]: values + if strings.HasPrefix(strings.TrimSpace(first.Content), "[") { + // Try TOON v2 inline array format + headerInfo := ParseArrayHeaderLineTOONv2(first.Content) if headerInfo != nil { cursor.Advance() - return decodeArrayFromHeader(headerInfo, "", cursor, 0, options) - } else { - // Try inline array - if arr, err := ParseInlineArray(first.Content); err == nil { - return arr, nil + + // Get inline values after colon + colonIdx := strings.Index(first.Content, ":") + if colonIdx != -1 { + inlineValues := strings.TrimSpace(first.Content[colonIdx+1:]) + if inlineValues != "" { + values := ParseDelimitedValues(inlineValues, headerInfo.Delimiter) + arr := make(JsonArray, len(values)) + for i, v := range values { + arr[i] = ParsePrimitiveToken(v) + } + return arr, nil + } } + + // No inline values - check for list format + if len(headerInfo.Fields) > 0 { + return decodeTabularArray(headerInfo, cursor, 0, options) + } + return decodeListArray(headerInfo, cursor, 0, options) + } + + // Try old format with [N|] + headerInfo = ParseArrayHeaderLine(first.Content, DelimiterComma) + if headerInfo != nil { + cursor.Advance() + return decodeArrayFromHeader(headerInfo, "", cursor, 0, options) + } + + // Try inline array like [item1, item2] + if arr, err := ParseInlineArray(first.Content); err == nil { + cursor.Advance() + return arr, nil } } @@ -86,14 +109,53 @@ func decodeObject(cursor *LineCursor, baseDepth int, options DecodeOptions) (Jso } func decodeKeyValue(content string, cursor *LineCursor, baseDepth int, options DecodeOptions) (string, JsonValue, error) { + // Check if content contains an array header pattern like "key[N]:" or "key[N]{fields}:" + // TOON v2 format: key[N]: values or key[N]{fields}: for tabular + + // Look for bracket pattern to extract key and array info + bracketStart := strings.Index(content, "[") + colonIdx := strings.Index(content, ":") + + if bracketStart != -1 && colonIdx != -1 && bracketStart < colonIdx { + // This might be an array header like "key[N]:" or "key[N]{fields}:" + key := strings.TrimSpace(content[:bracketStart]) + afterKey := content[bracketStart:] + + // Parse the array header + headerInfo := ParseArrayHeaderLineTOONv2(afterKey) + if headerInfo != nil { + headerInfo.Key = key + + // Get inline values after the colon if any + colonInAfterKey := strings.Index(afterKey, ":") + if colonInAfterKey != -1 { + inlineValues := strings.TrimSpace(afterKey[colonInAfterKey+1:]) + if inlineValues != "" { + // Parse inline values using delimiter + values := ParseDelimitedValues(inlineValues, headerInfo.Delimiter) + arr := make(JsonArray, len(values)) + for i, v := range values { + arr[i] = ParsePrimitiveToken(v) + } + return key, arr, nil + } + } + + // No inline values - check for list or tabular format + if len(headerInfo.Fields) > 0 { + arr, err := decodeTabularArray(headerInfo, cursor, baseDepth, options) + return key, arr, err + } + arr, err := decodeListArray(headerInfo, cursor, baseDepth, options) + return key, arr, err + } + } + // Simple key parsing (split by first colon) parts := strings.SplitN(content, ":", 2) key := strings.TrimSpace(parts[0]) if len(parts) < 2 { - // Should not happen if called correctly, or maybe it's a key without value (empty object?) - // If no colon, it might be an error or specific syntax. - // For now assume key: value return key, nil, fmt.Errorf("invalid key-value pair: %s", content) } @@ -109,22 +171,14 @@ func decodeKeyValue(content string, cursor *LineCursor, baseDepth int, options D return key, make(JsonObject), nil } - // Check for array header first (before parsing key) - // Actually, decodeKeyValue is called with the line content. - // If the line IS an array header, it's not a key-value pair. - // But decodeKeyValue is called by decodeObject which expects key-value. - // If we are here, we split by colon. - - // Check for array header - // e.g. "key: 3 |" -> rest is "3 |" + // Check for array header with old format (for backward compatibility) if IsArrayHeaderAfterHyphen(rest) { headerInfo := ParseArrayHeaderLine(rest, DelimiterComma) if headerInfo != nil { - // It is an array header! val, err := decodeArrayFromHeader(headerInfo, "", cursor, baseDepth, options) return key, val, err } else { - // Try inline array + // Try inline array with brackets like [item1, item2] if arr, err := ParseInlineArray(rest); err == nil { return key, arr, nil } @@ -217,17 +271,25 @@ func decodeObjectFromListItem(firstLine *ParsedLine, cursor *LineCursor, baseDep obj := JsonObject{key: value} - // Read subsequent fields at the same depth + // Sibling fields are at depth baseDepth + 1 (one level deeper than the list item line) + // because they align with the content after "- " + siblingDepth := baseDepth + 1 + for !cursor.AtEnd() { line := cursor.Peek() if line == nil || line.Depth < baseDepth { break } - // Must be same depth and NOT a list item (which would be next item in array) - if line.Depth == baseDepth && !strings.HasPrefix(line.Content, ListItemPrefix) && line.Content != "-" { + // If we see a line at list item depth that is a list item, we're done with this object + if line.Depth == baseDepth && (strings.HasPrefix(line.Content, ListItemPrefix) || line.Content == "-") { + break + } + + // Sibling fields should be at siblingDepth + if line.Depth == siblingDepth && !strings.HasPrefix(line.Content, ListItemPrefix) && line.Content != "-" { cursor.Advance() - k, v, err := decodeKeyValue(line.Content, cursor, baseDepth, options) + k, v, err := decodeKeyValue(line.Content, cursor, siblingDepth, options) if err != nil { return nil, err } diff --git a/pkg/toon/parser_utils.go b/pkg/toon/parser_utils.go index 2145b1e..c806e19 100644 --- a/pkg/toon/parser_utils.go +++ b/pkg/toon/parser_utils.go @@ -33,15 +33,94 @@ func ParsePrimitiveToken(token string) JsonValue { } // Return as string (unquoted) - // Note: In a full implementation, we might want to handle quoted strings explicitly - // to support escape sequences, but for now we'll assume simple strings. - if strings.HasPrefix(token, "\"") && strings.HasSuffix(token, "\"") { - return token[1 : len(token)-1] + // Handle quoted strings with escape sequences + if strings.HasPrefix(token, "\"") && strings.HasSuffix(token, "\"") && len(token) >= 2 { + inner := token[1 : len(token)-1] + // Unescape + inner = strings.ReplaceAll(inner, "\\\"", "\"") + inner = strings.ReplaceAll(inner, "\\\\", "\\") + inner = strings.ReplaceAll(inner, "\\n", "\n") + inner = strings.ReplaceAll(inner, "\\r", "\r") + inner = strings.ReplaceAll(inner, "\\t", "\t") + return inner } return token } +// ParseArrayHeaderLineTOONv2 parses a TOON v2 array header +// Format: [N]: or [N]{fields}: or [N|]: (pipe delimiter) or [N ]: (tab delimiter) +// Example: "[3]: 1,2,3" or "[2]{id,name}:" or "[3|]: a|b|c" +func ParseArrayHeaderLineTOONv2(line string) *ArrayHeaderInfo { + startBracket := strings.Index(line, "[") + if startBracket == -1 { + return nil + } + endBracket := strings.Index(line, "]") + if endBracket == -1 || endBracket <= startBracket { + return nil + } + + // Parse bracket content [N] or [N|] or [N ] + bracketContent := line[startBracket+1 : endBracket] + + // Find end of length (digits) + var i int + for i = 0; i < len(bracketContent); i++ { + if bracketContent[i] < '0' || bracketContent[i] > '9' { + break + } + } + + if i == 0 { + // No digits at start + return nil + } + + lengthStr := bracketContent[:i] + length, err := strconv.Atoi(lengthStr) + if err != nil { + return nil + } + + // Determine delimiter + delimiter := "," // Default is comma + rest := bracketContent[i:] + + if strings.HasPrefix(rest, "|") { + delimiter = "|" + } else if strings.HasPrefix(rest, "\t") { + delimiter = "\t" + } + // If rest is empty or just whitespace, delimiter stays as comma + + // Check for fields segment after bracket: {field1,field2} + afterBracket := strings.TrimSpace(line[endBracket+1:]) + var fields []string + + if strings.HasPrefix(afterBracket, "{") { + closeBrace := strings.Index(afterBracket, "}") + if closeBrace != -1 { + fieldsContent := afterBracket[1:closeBrace] + fields = ParseDelimitedValues(fieldsContent, delimiter) + afterBracket = strings.TrimSpace(afterBracket[closeBrace+1:]) + } + } + + // Check for colon + if !strings.HasPrefix(afterBracket, ":") { + // Not a valid array header (no colon) + return nil + } + + return &ArrayHeaderInfo{ + Key: "", // Key will be set by caller + Length: length, + Delimiter: delimiter, + Fields: fields, + } +} + // ParseArrayHeaderLine parses a line to check if it's an array header // Format: [key]: [length] [fields...] // Example: "items: [3|] name age" diff --git a/tests/test.toon b/tests/test.toon index b01b0ec..78ddb0f 100644 --- a/tests/test.toon +++ b/tests/test.toon @@ -1,14 +1,11 @@ -config: +config: indent: 2 strict: true -contributors: [2|] +contributors[2]: - name: Alice - role: Maintainer + role: Maintainer - name: Bob - role: Contributor -features: [3|] - - parser - - encoder - - cli + role: Contributor +features[3]: parser,encoder,cli name: Goon -version: 1 +version: 1 \ No newline at end of file diff --git a/tests/test_advanced.toon b/tests/test_advanced.toon index 4cf5569..54ea589 100644 --- a/tests/test_advanced.toon +++ b/tests/test_advanced.toon @@ -1,7 +1,7 @@ -inline_array: [one, two, three] -mixed_array: [1, "two", 3.5] -tabular_array: [2|name|role] +inline_array[3]: one,two,three +mixed_array[3]: "1","two","3.5" +tabular_array[2|]{name|role}: Alice|Maintainer Bob|Contributor nested_inline: - items: [a, b, c] + items[3]: a,b,c \ No newline at end of file diff --git a/tests/test_parity.toon b/tests/test_parity.toon index 11ce47b..0222f45 100644 --- a/tests/test_parity.toon +++ b/tests/test_parity.toon @@ -1,7 +1,7 @@ # This is a comment # Root array test -[3|name|role] +[3|]{name|role}: Alice|Maintainer # Comment inside array Bob|Contributor - Charlie|User + Charlie|User \ No newline at end of file From 626594ae30a225c8f4b1fef9224e3c3d72dd21af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 01:25:46 +0000 Subject: [PATCH 3/6] Fix escape sequence handling order in parser Co-authored-by: tnfssc <29162020+tnfssc@users.noreply.github.com> --- pkg/toon/parser_utils.go | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/pkg/toon/parser_utils.go b/pkg/toon/parser_utils.go index c806e19..19a4a6a 100644 --- a/pkg/toon/parser_utils.go +++ b/pkg/toon/parser_utils.go @@ -36,13 +36,38 @@ func ParsePrimitiveToken(token string) JsonValue { // Handle quoted strings with escape sequences if strings.HasPrefix(token, "\"") && strings.HasSuffix(token, "\"") && len(token) >= 2 { inner := token[1 : len(token)-1] - // Unescape - inner = strings.ReplaceAll(inner, "\\\"", "\"") - inner = strings.ReplaceAll(inner, "\\\\", "\\") - inner = strings.ReplaceAll(inner, "\\n", "\n") - inner = strings.ReplaceAll(inner, "\\r", "\r") - inner = strings.ReplaceAll(inner, "\\t", "\t") - return inner + // Unescape: process escape sequences character by character + var result strings.Builder + i := 0 + for i < len(inner) { + if inner[i] == '\\' && i+1 < len(inner) { + switch inner[i+1] { + case '\\': + result.WriteByte('\\') + i += 2 + case '"': + result.WriteByte('"') + i += 2 + case 'n': + result.WriteByte('\n') + i += 2 + case 'r': + result.WriteByte('\r') + i += 2 + case 't': + result.WriteByte('\t') + i += 2 + default: + // Unknown escape, keep as is + result.WriteByte(inner[i]) + i++ + } + } else { + result.WriteByte(inner[i]) + i++ + } + } + return result.String() } return token From c022deed4ecd87b34a6828199ae6fbac016948d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 01:26:39 +0000 Subject: [PATCH 4/6] Add escape sequence tests Co-authored-by: tnfssc <29162020+tnfssc@users.noreply.github.com> --- pkg/toon/marshal_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkg/toon/marshal_test.go b/pkg/toon/marshal_test.go index 816aab3..e319886 100644 --- a/pkg/toon/marshal_test.go +++ b/pkg/toon/marshal_test.go @@ -347,6 +347,35 @@ func TestUnmarshalPrimitiveTypes(t *testing.T) { } } +func TestEscapeSequences(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"simple newline", `"hello\nworld"`, "hello\nworld"}, + {"simple tab", `"hello\tworld"`, "hello\tworld"}, + {"simple quote", `"hello\"world"`, "hello\"world"}, + {"simple backslash", `"hello\\world"`, "hello\\world"}, + {"backslash then n", `"hello\\nworld"`, "hello\\nworld"}, + {"carriage return", `"hello\rworld"`, "hello\rworld"}, + {"multiple escapes", `"a\\b\"c\nd"`, "a\\b\"c\nd"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result string + err := Unmarshal([]byte(tt.input), &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if result != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, result) + } + }) + } +} + // Helper function to check if a string contains a substring func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) From 2184b1cdd24b7e97f589f4246f6ee79876808a21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:10:31 +0000 Subject: [PATCH 5/6] Add TOON v1/v2 format support with version flag and comprehensive tests Co-authored-by: tnfssc <29162020+tnfssc@users.noreply.github.com> --- pkg/toon/encoder.go | 205 ++++++++- pkg/toon/types.go | 11 + pkg/toon/version_test.go | 903 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1116 insertions(+), 3 deletions(-) create mode 100644 pkg/toon/version_test.go diff --git a/pkg/toon/encoder.go b/pkg/toon/encoder.go index 39224dd..c98dde7 100644 --- a/pkg/toon/encoder.go +++ b/pkg/toon/encoder.go @@ -194,10 +194,14 @@ func encodeObject(sb *strings.Builder, obj map[string]interface{}, depth int, op // encodeArrayWithKey encodes an array as a value for a key (key already written, need to write header and content) func encodeArrayWithKey(sb *strings.Builder, arr []interface{}, depth int, options EncodeOptions) error { - // TOON v2 array format: key[N]: for comma delimiter (default) - // For primitive arrays: inline format - key[N]: v1,v2,v3 - // For object arrays: list format - key[N]: then items with - prefix + // V2 format: key[N]: for comma delimiter (default) + // V1 format: key: [N|] with list items + if options.Version == V1 { + return encodeArrayWithKeyV1(sb, arr, depth, options) + } + + // V2 format (default) if isPrimitiveArray(arr) { // Inline format for primitive arrays sb.WriteString(fmt.Sprintf("[%d]:", len(arr))) @@ -330,8 +334,109 @@ func encodeArrayWithKey(sb *strings.Builder, arr []interface{}, depth int, optio return nil } +// encodeArrayWithKeyV1 encodes an array in TOON v1 format (list style with [N|]) +func encodeArrayWithKeyV1(sb *strings.Builder, arr []interface{}, depth int, options EncodeOptions) error { + // V1 format: key: [N|] with list items for all arrays + sb.WriteString(fmt.Sprintf(": [%d|]", len(arr))) + + indent := strings.Repeat(" ", (depth+1)*options.IndentSize) + + for _, item := range arr { + sb.WriteString("\n") + sb.WriteString(indent) + sb.WriteString("- ") + + if obj, ok := item.(map[string]interface{}); ok { + if len(obj) == 0 { + // Empty object + } else { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + + firstKey := keys[0] + firstVal := obj[firstKey] + + sb.WriteString(firstKey) + + if arrVal, ok := firstVal.([]interface{}); ok { + if err := encodeArrayWithKeyV1(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := firstVal.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, firstVal, 0, options); err != nil { + return err + } + } + + subIndent := strings.Repeat(" ", (depth+1)*options.IndentSize) + for i := 1; i < len(keys); i++ { + k := keys[i] + v := obj[k] + sb.WriteString("\n") + sb.WriteString(subIndent) + sb.WriteString(k) + + if arrVal, ok := v.([]interface{}); ok { + if err := encodeArrayWithKeyV1(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := v.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, v, 0, options); err != nil { + return err + } + } + } + } + } else if nestedArr, ok := item.([]interface{}); ok { + // Nested array + sb.WriteString(fmt.Sprintf("[%d|]", len(nestedArr))) + nestedIndent := strings.Repeat(" ", (depth+2)*options.IndentSize) + for _, nestedItem := range nestedArr { + sb.WriteString("\n") + sb.WriteString(nestedIndent) + sb.WriteString("- ") + if err := encodeValue(sb, nestedItem, depth+2, options); err != nil { + return err + } + } + } else { + // Primitive item + if err := encodeValue(sb, item, 0, options); err != nil { + return err + } + } + } + return nil +} + func encodeArray(sb *strings.Builder, arr []interface{}, depth int, options EncodeOptions) error { // This is called when array is the root value + if options.Version == V1 { + return encodeArrayV1(sb, arr, depth, options) + } + + // V2 format (default) if isPrimitiveArray(arr) { // Inline format for primitive arrays at root sb.WriteString(fmt.Sprintf("[%d]:", len(arr))) @@ -450,3 +555,97 @@ func encodeArray(sb *strings.Builder, arr []interface{}, depth int, options Enco } return nil } + +// encodeArrayV1 encodes an array in TOON v1 format (list style) +func encodeArrayV1(sb *strings.Builder, arr []interface{}, depth int, options EncodeOptions) error { + // V1 format: [N|] with list items + sb.WriteString(fmt.Sprintf("[%d|]", len(arr))) + + indent := strings.Repeat(" ", (depth+1)*options.IndentSize) + + for _, item := range arr { + sb.WriteString("\n") + sb.WriteString(indent) + sb.WriteString("- ") + + if obj, ok := item.(map[string]interface{}); ok { + if len(obj) == 0 { + // Empty object + } else { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + + firstKey := keys[0] + firstVal := obj[firstKey] + + sb.WriteString(firstKey) + + if arrVal, ok := firstVal.([]interface{}); ok { + if err := encodeArrayWithKeyV1(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := firstVal.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, firstVal, 0, options); err != nil { + return err + } + } + + subIndent := strings.Repeat(" ", (depth+1)*options.IndentSize) + for i := 1; i < len(keys); i++ { + k := keys[i] + v := obj[k] + sb.WriteString("\n") + sb.WriteString(subIndent) + sb.WriteString(k) + + if arrVal, ok := v.([]interface{}); ok { + if err := encodeArrayWithKeyV1(sb, arrVal, depth+1, options); err != nil { + return err + } + } else if objVal, ok := v.(map[string]interface{}); ok { + sb.WriteString(":") + if len(objVal) > 0 { + sb.WriteString("\n") + if err := encodeObject(sb, objVal, depth+2, options); err != nil { + return err + } + } + } else { + sb.WriteString(": ") + if err := encodeValue(sb, v, 0, options); err != nil { + return err + } + } + } + } + } else if nestedArr, ok := item.([]interface{}); ok { + sb.WriteString(fmt.Sprintf("[%d|]", len(nestedArr))) + nestedIndent := strings.Repeat(" ", (depth+2)*options.IndentSize) + for _, nestedItem := range nestedArr { + sb.WriteString("\n") + sb.WriteString(nestedIndent) + sb.WriteString("- ") + if err := encodeValue(sb, nestedItem, depth+2, options); err != nil { + return err + } + } + } else { + if err := encodeValue(sb, item, 0, options); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/toon/types.go b/pkg/toon/types.go index 9b500cc..12f8103 100644 --- a/pkg/toon/types.go +++ b/pkg/toon/types.go @@ -22,6 +22,16 @@ type JsonObject map[string]JsonValue // JsonArray represents a JSON array type JsonArray []JsonValue +// TOONVersion represents the TOON format version +type TOONVersion int + +const ( + // V2 is the default TOON v2 format with inline primitive arrays + V2 TOONVersion = iota + // V1 is the legacy TOON v1 format with list-style arrays + V1 +) + // DecodeOptions configuration for decoding type DecodeOptions struct { IndentSize int @@ -31,4 +41,5 @@ type DecodeOptions struct { // EncodeOptions configuration for encoding type EncodeOptions struct { IndentSize int + Version TOONVersion // Default is V2, set to V1 for legacy format } diff --git a/pkg/toon/version_test.go b/pkg/toon/version_test.go new file mode 100644 index 0000000..c45ef59 --- /dev/null +++ b/pkg/toon/version_test.go @@ -0,0 +1,903 @@ +package toon + +import ( + "reflect" + "strings" + "testing" +) + +// ============================================================================= +// V2 FORMAT TESTS (Default) +// ============================================================================= + +func TestV2PrimitiveArrayInline(t *testing.T) { + // V2 encodes primitive arrays inline + data := map[string]interface{}{ + "tags": []interface{}{"a", "b", "c"}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "tags[3]: a,b,c" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +func TestV2PrimitiveArrayInlineNumbers(t *testing.T) { + data := map[string]interface{}{ + "nums": []interface{}{float64(1), float64(2), float64(3)}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "nums[3]: 1,2,3" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +func TestV2EmptyArray(t *testing.T) { + data := map[string]interface{}{ + "empty": []interface{}{}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "empty[0]:" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +func TestV2MixedPrimitiveArray(t *testing.T) { + data := map[string]interface{}{ + "mixed": []interface{}{"a", float64(1), true, false}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "mixed[4]: a,1,true,false" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +func TestV2ObjectArray(t *testing.T) { + data := map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{"id": float64(1), "name": "Alice"}, + map[string]interface{}{"id": float64(2), "name": "Bob"}, + }, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // V2 uses [N]: format + if !strings.Contains(string(result), "users[2]:") { + t.Errorf("Expected 'users[2]:' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- id:") { + t.Errorf("Expected list items in output:\n%s", string(result)) + } +} + +func TestV2RootPrimitiveArray(t *testing.T) { + arr := []int{10, 20, 30} + + result, err := Marshal(arr, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "[3]: 10,20,30" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +func TestV2QuotedStrings(t *testing.T) { + data := map[string]interface{}{ + "nums": []interface{}{"123", "true", "null"}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Numeric-like strings should be quoted + if !strings.Contains(string(result), `"123"`) { + t.Errorf("Expected quoted '123' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), `"true"`) { + t.Errorf("Expected quoted 'true' in output:\n%s", string(result)) + } +} + +func TestV2NestedArrays(t *testing.T) { + data := map[string]interface{}{ + "matrix": []interface{}{ + []interface{}{float64(1), float64(2)}, + []interface{}{float64(3), float64(4)}, + }, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // V2 should handle nested arrays + if !strings.Contains(string(result), "matrix[2]:") { + t.Errorf("Expected 'matrix[2]:' in output:\n%s", string(result)) + } +} + +// ============================================================================= +// V1 FORMAT TESTS +// ============================================================================= + +func TestV1PrimitiveArrayList(t *testing.T) { + // V1 encodes all arrays as lists with [N|] + data := map[string]interface{}{ + "tags": []interface{}{"a", "b", "c"}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // V1 format uses [N|] with list items + if !strings.Contains(string(result), "[3|]") { + t.Errorf("Expected '[3|]' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- a") { + t.Errorf("Expected '- a' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- b") { + t.Errorf("Expected '- b' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- c") { + t.Errorf("Expected '- c' in output:\n%s", string(result)) + } +} + +func TestV1PrimitiveArrayListNumbers(t *testing.T) { + data := map[string]interface{}{ + "nums": []interface{}{float64(1), float64(2), float64(3)}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + if !strings.Contains(string(result), "[3|]") { + t.Errorf("Expected '[3|]' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- 1") { + t.Errorf("Expected '- 1' in output:\n%s", string(result)) + } +} + +func TestV1EmptyArray(t *testing.T) { + data := map[string]interface{}{ + "empty": []interface{}{}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + if !strings.Contains(string(result), "[0|]") { + t.Errorf("Expected '[0|]' in output:\n%s", string(result)) + } +} + +func TestV1ObjectArray(t *testing.T) { + data := map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{"id": float64(1), "name": "Alice"}, + map[string]interface{}{"id": float64(2), "name": "Bob"}, + }, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // V1 uses [N|] format + if !strings.Contains(string(result), "[2|]") { + t.Errorf("Expected '[2|]' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- id:") { + t.Errorf("Expected list items in output:\n%s", string(result)) + } +} + +func TestV1RootPrimitiveArray(t *testing.T) { + arr := []int{10, 20, 30} + + result, err := Marshal(arr, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + if !strings.Contains(string(result), "[3|]") { + t.Errorf("Expected '[3|]' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- 10") { + t.Errorf("Expected '- 10' in output:\n%s", string(result)) + } +} + +func TestV1NestedArrays(t *testing.T) { + data := map[string]interface{}{ + "matrix": []interface{}{ + []interface{}{float64(1), float64(2)}, + []interface{}{float64(3), float64(4)}, + }, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // V1 should use [N|] for nested arrays too + if !strings.Contains(string(result), "[2|]") { + t.Errorf("Expected '[2|]' in output:\n%s", string(result)) + } +} + +// ============================================================================= +// DECODER TESTS - V1 FORMAT BACKWARD COMPATIBILITY +// ============================================================================= + +func TestDecodeV1PrimitiveArrayList(t *testing.T) { + toonData := []byte(`tags: [3|] + - a + - b + - c`) + + var result map[string]interface{} + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + // Handle both []interface{} and JsonArray types + tags := result["tags"] + var tagSlice []interface{} + switch v := tags.(type) { + case []interface{}: + tagSlice = v + case JsonArray: + tagSlice = make([]interface{}, len(v)) + for i, item := range v { + tagSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", tags) + } + + expected := []interface{}{"a", "b", "c"} + if !reflect.DeepEqual(tagSlice, expected) { + t.Errorf("Expected %v, got %v", expected, tagSlice) + } +} + +func TestDecodeV1NumberArrayList(t *testing.T) { + toonData := []byte(`nums: [3|] + - 1 + - 2 + - 3`) + + var result map[string]interface{} + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + nums := result["nums"] + var numSlice []interface{} + switch v := nums.(type) { + case []interface{}: + numSlice = v + case JsonArray: + numSlice = make([]interface{}, len(v)) + for i, item := range v { + numSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", nums) + } + + expected := []interface{}{float64(1), float64(2), float64(3)} + if !reflect.DeepEqual(numSlice, expected) { + t.Errorf("Expected %v, got %v", expected, numSlice) + } +} + +func TestDecodeV1ObjectArray(t *testing.T) { + toonData := []byte(`users: [2|] + - id: 1 + name: Alice + - id: 2 + name: Bob`) + + var result map[string]interface{} + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + users := result["users"] + var usersSlice []interface{} + switch v := users.(type) { + case []interface{}: + usersSlice = v + case JsonArray: + usersSlice = make([]interface{}, len(v)) + for i, item := range v { + usersSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", users) + } + + if len(usersSlice) != 2 { + t.Errorf("Expected 2 users, got %d", len(usersSlice)) + } +} + +func TestDecodeV1RootArray(t *testing.T) { + toonData := []byte(`[3|] + - 10 + - 20 + - 30`) + + var result []int + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + expected := []int{10, 20, 30} + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, got %v", expected, result) + } +} + +// ============================================================================= +// DECODER TESTS - V2 FORMAT +// ============================================================================= + +func TestDecodeV2PrimitiveArrayInline(t *testing.T) { + toonData := []byte(`tags[3]: a,b,c`) + + var result map[string]interface{} + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + tags := result["tags"] + var tagSlice []interface{} + switch v := tags.(type) { + case []interface{}: + tagSlice = v + case JsonArray: + tagSlice = make([]interface{}, len(v)) + for i, item := range v { + tagSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", tags) + } + + expected := []interface{}{"a", "b", "c"} + if !reflect.DeepEqual(tagSlice, expected) { + t.Errorf("Expected %v, got %v", expected, tagSlice) + } +} + +func TestDecodeV2NumberArrayInline(t *testing.T) { + toonData := []byte(`nums[3]: 1,2,3`) + + var result map[string]interface{} + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + nums := result["nums"] + var numSlice []interface{} + switch v := nums.(type) { + case []interface{}: + numSlice = v + case JsonArray: + numSlice = make([]interface{}, len(v)) + for i, item := range v { + numSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", nums) + } + + expected := []interface{}{float64(1), float64(2), float64(3)} + if !reflect.DeepEqual(numSlice, expected) { + t.Errorf("Expected %v, got %v", expected, numSlice) + } +} + +func TestDecodeV2EmptyArray(t *testing.T) { + toonData := []byte(`empty[0]:`) + + var result map[string]interface{} + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + empty := result["empty"] + var emptySlice []interface{} + switch v := empty.(type) { + case []interface{}: + emptySlice = v + case JsonArray: + emptySlice = make([]interface{}, len(v)) + for i, item := range v { + emptySlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", empty) + } + + if len(emptySlice) != 0 { + t.Errorf("Expected empty array, got %v", emptySlice) + } +} + +func TestDecodeV2ObjectArray(t *testing.T) { + toonData := []byte(`users[2]: + - id: 1 + name: Alice + - id: 2 + name: Bob`) + + var result map[string]interface{} + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + users := result["users"] + var usersSlice []interface{} + switch v := users.(type) { + case []interface{}: + usersSlice = v + case JsonArray: + usersSlice = make([]interface{}, len(v)) + for i, item := range v { + usersSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", users) + } + + if len(usersSlice) != 2 { + t.Errorf("Expected 2 users, got %d", len(usersSlice)) + } +} + +func TestDecodeV2RootArrayInline(t *testing.T) { + toonData := []byte(`[3]: 10,20,30`) + + var result []int + err := Unmarshal(toonData, &result, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + expected := []int{10, 20, 30} + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, got %v", expected, result) + } +} + +// ============================================================================= +// ROUND-TRIP TESTS +// ============================================================================= + +func TestV2RoundTripPrimitiveArray(t *testing.T) { + original := map[string]interface{}{ + "items": []interface{}{"a", "b", "c"}, + } + + // Marshal with V2 + data, err := Marshal(original, EncodeOptions{IndentSize: 2, Version: V2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Unmarshal + var decoded map[string]interface{} + err = Unmarshal(data, &decoded, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + items := decoded["items"] + var itemsSlice []interface{} + switch v := items.(type) { + case []interface{}: + itemsSlice = v + case JsonArray: + itemsSlice = make([]interface{}, len(v)) + for i, item := range v { + itemsSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", items) + } + + if !reflect.DeepEqual(itemsSlice, original["items"]) { + t.Errorf("Round-trip failed: expected %v, got %v", original["items"], itemsSlice) + } +} + +func TestV1RoundTripPrimitiveArray(t *testing.T) { + original := map[string]interface{}{ + "items": []interface{}{"a", "b", "c"}, + } + + // Marshal with V1 + data, err := Marshal(original, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Unmarshal + var decoded map[string]interface{} + err = Unmarshal(data, &decoded, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + items := decoded["items"] + var itemsSlice []interface{} + switch v := items.(type) { + case []interface{}: + itemsSlice = v + case JsonArray: + itemsSlice = make([]interface{}, len(v)) + for i, item := range v { + itemsSlice[i] = item + } + default: + t.Fatalf("Expected array type, got %T", items) + } + + if !reflect.DeepEqual(itemsSlice, original["items"]) { + t.Errorf("Round-trip failed: expected %v, got %v", original["items"], itemsSlice) + } +} + +func TestV2RoundTripComplexStructure(t *testing.T) { + original := map[string]interface{}{ + "name": "test", + "tags": []interface{}{"a", "b"}, + "data": map[string]interface{}{ + "nums": []interface{}{float64(1), float64(2), float64(3)}, + "flag": true, + "value": float64(42), + }, + } + + // Marshal with V2 + data, err := Marshal(original, EncodeOptions{IndentSize: 2, Version: V2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Unmarshal + var decoded map[string]interface{} + err = Unmarshal(data, &decoded, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded["name"] != original["name"] { + t.Errorf("name mismatch: expected %v, got %v", original["name"], decoded["name"]) + } + + tags := decoded["tags"] + var tagsSlice []interface{} + switch v := tags.(type) { + case []interface{}: + tagsSlice = v + case JsonArray: + tagsSlice = make([]interface{}, len(v)) + for i, item := range v { + tagsSlice[i] = item + } + default: + t.Fatalf("Expected array type for tags, got %T", tags) + } + if !reflect.DeepEqual(tagsSlice, original["tags"]) { + t.Errorf("tags mismatch: expected %v, got %v", original["tags"], tagsSlice) + } +} + +func TestV1RoundTripComplexStructure(t *testing.T) { + original := map[string]interface{}{ + "name": "test", + "tags": []interface{}{"a", "b"}, + "data": map[string]interface{}{ + "nums": []interface{}{float64(1), float64(2), float64(3)}, + "flag": true, + "value": float64(42), + }, + } + + // Marshal with V1 + data, err := Marshal(original, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Unmarshal + var decoded map[string]interface{} + err = Unmarshal(data, &decoded, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded["name"] != original["name"] { + t.Errorf("name mismatch: expected %v, got %v", original["name"], decoded["name"]) + } + + tags := decoded["tags"] + var tagsSlice []interface{} + switch v := tags.(type) { + case []interface{}: + tagsSlice = v + case JsonArray: + tagsSlice = make([]interface{}, len(v)) + for i, item := range v { + tagsSlice[i] = item + } + default: + t.Fatalf("Expected array type for tags, got %T", tags) + } + if !reflect.DeepEqual(tagsSlice, original["tags"]) { + t.Errorf("tags mismatch: expected %v, got %v", original["tags"], tagsSlice) + } +} + +// ============================================================================= +// VERSION CONSTANT TESTS +// ============================================================================= + +func TestVersionConstants(t *testing.T) { + // V2 should be default (0) + if V2 != 0 { + t.Errorf("Expected V2 to be 0 (default), got %d", V2) + } + // V1 should be 1 + if V1 != 1 { + t.Errorf("Expected V1 to be 1, got %d", V1) + } +} + +func TestDefaultVersionIsV2(t *testing.T) { + // An empty EncodeOptions should default to V2 + opts := EncodeOptions{IndentSize: 2} + if opts.Version != V2 { + t.Errorf("Expected default Version to be V2, got %d", opts.Version) + } +} + +// ============================================================================= +// EDGE CASE TESTS +// ============================================================================= + +func TestV2ArrayWithSpecialCharacters(t *testing.T) { + data := map[string]interface{}{ + "special": []interface{}{"hello, world", "a:b", "test\"quote"}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Strings with commas, colons, quotes should be quoted + if !strings.Contains(string(result), `"hello, world"`) { + t.Errorf("Expected quoted 'hello, world' in output:\n%s", string(result)) + } +} + +func TestV1ArrayWithSpecialCharacters(t *testing.T) { + data := map[string]interface{}{ + "special": []interface{}{"hello, world", "a:b"}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // V1 list format should still quote special characters + if !strings.Contains(string(result), `"hello, world"`) { + t.Errorf("Expected quoted 'hello, world' in output:\n%s", string(result)) + } +} + +func TestV2SingleElementArray(t *testing.T) { + data := map[string]interface{}{ + "single": []interface{}{"only"}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "single[1]: only" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +func TestV1SingleElementArray(t *testing.T) { + data := map[string]interface{}{ + "single": []interface{}{"only"}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + if !strings.Contains(string(result), "[1|]") { + t.Errorf("Expected '[1|]' in output:\n%s", string(result)) + } + if !strings.Contains(string(result), "- only") { + t.Errorf("Expected '- only' in output:\n%s", string(result)) + } +} + +func TestV2BooleanArray(t *testing.T) { + data := map[string]interface{}{ + "flags": []interface{}{true, false, true}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "flags[3]: true,false,true" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +func TestV2NullArray(t *testing.T) { + data := map[string]interface{}{ + "nulls": []interface{}{nil, nil}, + } + + result, err := Marshal(data, EncodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := "nulls[2]: null,null" + if string(result) != expected { + t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, string(result)) + } +} + +// ============================================================================= +// STRUCT ROUND-TRIP TESTS +// ============================================================================= + +type TestData struct { + Name string `toon:"name"` + Items []string `toon:"items"` + Count int `toon:"count"` +} + +func TestV2StructRoundTrip(t *testing.T) { + original := TestData{ + Name: "test", + Items: []string{"a", "b", "c"}, + Count: 42, + } + + // Marshal with V2 + data, err := Marshal(original, EncodeOptions{IndentSize: 2, Version: V2}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Should use V2 format + if !strings.Contains(string(data), "items[3]:") { + t.Errorf("Expected V2 format 'items[3]:' in output:\n%s", string(data)) + } + + // Unmarshal + var decoded TestData + err = Unmarshal(data, &decoded, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.Name != original.Name { + t.Errorf("Name mismatch: expected %v, got %v", original.Name, decoded.Name) + } + if !reflect.DeepEqual(decoded.Items, original.Items) { + t.Errorf("Items mismatch: expected %v, got %v", original.Items, decoded.Items) + } + if decoded.Count != original.Count { + t.Errorf("Count mismatch: expected %v, got %v", original.Count, decoded.Count) + } +} + +func TestV1StructRoundTrip(t *testing.T) { + original := TestData{ + Name: "test", + Items: []string{"a", "b", "c"}, + Count: 42, + } + + // Marshal with V1 + data, err := Marshal(original, EncodeOptions{IndentSize: 2, Version: V1}) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Should use V1 format + if !strings.Contains(string(data), "[3|]") { + t.Errorf("Expected V1 format '[3|]' in output:\n%s", string(data)) + } + + // Unmarshal + var decoded TestData + err = Unmarshal(data, &decoded, DecodeOptions{IndentSize: 2}) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.Name != original.Name { + t.Errorf("Name mismatch: expected %v, got %v", original.Name, decoded.Name) + } + if !reflect.DeepEqual(decoded.Items, original.Items) { + t.Errorf("Items mismatch: expected %v, got %v", original.Items, decoded.Items) + } + if decoded.Count != original.Count { + t.Errorf("Count mismatch: expected %v, got %v", original.Count, decoded.Count) + } +} From df22f92c40ed55b8e09cc3cc98987aee3a3e8420 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:43:53 +0000 Subject: [PATCH 6/6] Fix PR review comments: isPrimitive types, empty object handling, and test name Co-authored-by: tnfssc <29162020+tnfssc@users.noreply.github.com> --- pkg/toon/encoder.go | 33 +++++++++++++++++++++------------ pkg/toon/marshal_test.go | 2 +- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/pkg/toon/encoder.go b/pkg/toon/encoder.go index c98dde7..937f71d 100644 --- a/pkg/toon/encoder.go +++ b/pkg/toon/encoder.go @@ -24,7 +24,7 @@ func encode(value JsonValue, options EncodeOptions) (string, error) { // isPrimitive checks if a value is a primitive (not object or array) func isPrimitive(v interface{}) bool { switch v.(type) { - case map[string]interface{}, []interface{}: + case map[string]interface{}, []interface{}, JsonObject, JsonArray: return false default: return true @@ -223,16 +223,14 @@ func encodeArrayWithKey(sb *strings.Builder, arr []interface{}, depth int, optio for _, item := range arr { sb.WriteString("\n") sb.WriteString(indent) - sb.WriteString("- ") if obj, ok := item.(map[string]interface{}); ok { // Special handling for object in list if len(obj) == 0 { - // Empty object - just the hyphen (remove the space we added) - // Actually, per spec, empty object is just "-" on its own line - // We already wrote "- ", so we need to handle this better - // Let's trim the space we added + // Empty object - just the hyphen, per spec + sb.WriteString("-") } else { + sb.WriteString("- ") keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) @@ -297,6 +295,7 @@ func encodeArrayWithKey(sb *strings.Builder, arr []interface{}, depth int, optio } } else if nestedArr, ok := item.([]interface{}); ok { // Nested array as list item + sb.WriteString("- ") // Format: - [M]: v1,v2,... for primitive arrays // or - [M]: with list items for complex arrays if isPrimitiveArray(nestedArr) { @@ -325,6 +324,7 @@ func encodeArrayWithKey(sb *strings.Builder, arr []interface{}, depth int, optio } } else { // Primitive item + sb.WriteString("- ") if err := encodeValue(sb, item, 0, options); err != nil { return err } @@ -344,12 +344,13 @@ func encodeArrayWithKeyV1(sb *strings.Builder, arr []interface{}, depth int, opt for _, item := range arr { sb.WriteString("\n") sb.WriteString(indent) - sb.WriteString("- ") if obj, ok := item.(map[string]interface{}); ok { if len(obj) == 0 { - // Empty object + // Empty object - just the hyphen, per spec + sb.WriteString("-") } else { + sb.WriteString("- ") keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) @@ -410,6 +411,7 @@ func encodeArrayWithKeyV1(sb *strings.Builder, arr []interface{}, depth int, opt } } else if nestedArr, ok := item.([]interface{}); ok { // Nested array + sb.WriteString("- ") sb.WriteString(fmt.Sprintf("[%d|]", len(nestedArr))) nestedIndent := strings.Repeat(" ", (depth+2)*options.IndentSize) for _, nestedItem := range nestedArr { @@ -422,6 +424,7 @@ func encodeArrayWithKeyV1(sb *strings.Builder, arr []interface{}, depth int, opt } } else { // Primitive item + sb.WriteString("- ") if err := encodeValue(sb, item, 0, options); err != nil { return err } @@ -458,12 +461,13 @@ func encodeArray(sb *strings.Builder, arr []interface{}, depth int, options Enco for _, item := range arr { sb.WriteString("\n") sb.WriteString(indent) - sb.WriteString("- ") if obj, ok := item.(map[string]interface{}); ok { if len(obj) == 0 { - // Empty object - remove the trailing space from "- " + // Empty object - just the hyphen, per spec + sb.WriteString("-") } else { + sb.WriteString("- ") keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) @@ -523,6 +527,7 @@ func encodeArray(sb *strings.Builder, arr []interface{}, depth int, options Enco } } } else if nestedArr, ok := item.([]interface{}); ok { + sb.WriteString("- ") if isPrimitiveArray(nestedArr) { sb.WriteString(fmt.Sprintf("[%d]:", len(nestedArr))) if len(nestedArr) > 0 { @@ -547,6 +552,7 @@ func encodeArray(sb *strings.Builder, arr []interface{}, depth int, options Enco } } } else { + sb.WriteString("- ") if err := encodeValue(sb, item, 0, options); err != nil { return err } @@ -566,12 +572,13 @@ func encodeArrayV1(sb *strings.Builder, arr []interface{}, depth int, options En for _, item := range arr { sb.WriteString("\n") sb.WriteString(indent) - sb.WriteString("- ") if obj, ok := item.(map[string]interface{}); ok { if len(obj) == 0 { - // Empty object + // Empty object - just the hyphen, per spec + sb.WriteString("-") } else { + sb.WriteString("- ") keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) @@ -631,6 +638,7 @@ func encodeArrayV1(sb *strings.Builder, arr []interface{}, depth int, options En } } } else if nestedArr, ok := item.([]interface{}); ok { + sb.WriteString("- ") sb.WriteString(fmt.Sprintf("[%d|]", len(nestedArr))) nestedIndent := strings.Repeat(" ", (depth+2)*options.IndentSize) for _, nestedItem := range nestedArr { @@ -642,6 +650,7 @@ func encodeArrayV1(sb *strings.Builder, arr []interface{}, depth int, options En } } } else { + sb.WriteString("- ") if err := encodeValue(sb, item, 0, options); err != nil { return err } diff --git a/pkg/toon/marshal_test.go b/pkg/toon/marshal_test.go index e319886..096c025 100644 --- a/pkg/toon/marshal_test.go +++ b/pkg/toon/marshal_test.go @@ -357,7 +357,7 @@ func TestEscapeSequences(t *testing.T) { {"simple tab", `"hello\tworld"`, "hello\tworld"}, {"simple quote", `"hello\"world"`, "hello\"world"}, {"simple backslash", `"hello\\world"`, "hello\\world"}, - {"backslash then n", `"hello\\nworld"`, "hello\\nworld"}, + {"literal backslash-n", `"hello\\nworld"`, "hello\\nworld"}, {"carriage return", `"hello\rworld"`, "hello\rworld"}, {"multiple escapes", `"a\\b\"c\nd"`, "a\\b\"c\nd"}, }