@@ -27,6 +27,21 @@ use crate::{
2727 utils:: validation:: validate_depth,
2828} ;
2929
30+ /// Context for parsing arrays to determine correct indentation depth.
31+ ///
32+ /// Arrays as the first field of list-item objects require special indentation:
33+ /// their content (rows for tabular, items for non-uniform) appears at depth +2
34+ /// relative to the hyphen line, while arrays in other contexts use depth +1.
35+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
36+ enum ArrayParseContext {
37+ /// Normal array parsing context (content at depth +1)
38+ Normal ,
39+
40+ /// Array as first field of list-item object
41+ /// (content at depth +2 relative to hyphen line)
42+ ListItemFirstField ,
43+ }
44+
3045/// Parser that builds JSON values from a sequence of tokens.
3146#[ allow( unused) ]
3247pub struct Parser < ' a > {
@@ -512,17 +527,16 @@ impl<'a> Parser<'a> {
512527 }
513528 self . advance ( ) ?;
514529
515- // Parse array length (plain integer only per TOON spec v2.0 )
530+ // Parse array length (plain integer only)
516531 // Supports formats: [N], [N|], [N\t] (no # marker)
517532 let length = if let Token :: Integer ( n) = & self . current_token {
518533 * n as usize
519534 } else if let Token :: String ( s, _) = & self . current_token {
520- // Check if string starts with # - this is now invalid per spec v2.0
535+ // Check if string starts with # - this marker is not supported
521536 if s. starts_with ( '#' ) {
522537 return Err ( self
523538 . parse_error_with_context (
524- "Length marker '#' is no longer supported in TOON spec v2.0. Use [N] \
525- format instead of [#N]",
539+ "Length marker '#' is not supported. Use [N] format instead of [#N]" ,
526540 )
527541 . with_suggestion ( "Remove the '#' prefix from the array length" ) ) ;
528542 }
@@ -623,15 +637,29 @@ impl<'a> Parser<'a> {
623637 }
624638
625639 fn parse_array ( & mut self , depth : usize ) -> ToonResult < Value > {
640+ self . parse_array_with_context ( depth, ArrayParseContext :: Normal )
641+ }
642+
643+ fn parse_array_with_context (
644+ & mut self ,
645+ depth : usize ,
646+ context : ArrayParseContext ,
647+ ) -> ToonResult < Value > {
626648 validate_depth ( depth, MAX_DEPTH ) ?;
627649
628650 let ( length, _detected_delim, fields) = self . parse_array_header ( ) ?;
629651
630652 if let Some ( fields) = fields {
631653 validation:: validate_field_list ( & fields) ?;
632- self . parse_tabular_array ( length, fields, depth)
654+ self . parse_tabular_array ( length, fields, depth, context )
633655 } else {
634- self . parse_regular_array ( length, depth)
656+ // Non-tabular arrays as first field of list items require depth adjustment
657+ // (items at depth +2 relative to hyphen, not the usual +1)
658+ let adjusted_depth = match context {
659+ ArrayParseContext :: Normal => depth,
660+ ArrayParseContext :: ListItemFirstField => depth + 1 ,
661+ } ;
662+ self . parse_regular_array ( length, adjusted_depth)
635663 }
636664 }
637665
@@ -640,6 +668,7 @@ impl<'a> Parser<'a> {
640668 length : usize ,
641669 fields : Vec < String > ,
642670 depth : usize ,
671+ context : ArrayParseContext ,
643672 ) -> ToonResult < Value > {
644673 let mut rows = Vec :: new ( ) ;
645674
@@ -663,7 +692,14 @@ impl<'a> Parser<'a> {
663692 }
664693
665694 let current_indent = self . scanner . get_last_line_indent ( ) ;
666- let expected_indent = self . options . indent . get_spaces ( ) * ( depth + 1 ) ;
695+
696+ // Tabular arrays as first field of list-item objects require rows at depth +2
697+ // (relative to hyphen), while normal tabular arrays use depth +1
698+ let row_depth_offset = match context {
699+ ArrayParseContext :: Normal => 1 ,
700+ ArrayParseContext :: ListItemFirstField => 2 ,
701+ } ;
702+ let expected_indent = self . options . indent . get_spaces ( ) * ( depth + row_depth_offset) ;
667703
668704 if self . options . strict {
669705 self . validate_indentation ( current_indent) ?;
@@ -861,12 +897,21 @@ impl<'a> Parser<'a> {
861897
862898 if matches ! ( self . current_token, Token :: Colon | Token :: LeftBracket ) {
863899 // This is an object: key followed by colon or array bracket
900+ // First field of list-item object may be an array requiring special
901+ // indentation
864902 let first_value = if matches ! ( self . current_token, Token :: LeftBracket ) {
865- self . parse_array ( depth + 1 ) ?
903+ // Array directly after key (e.g., "- key[N]:")
904+ // Use ListItemFirstField context to apply correct indentation
905+ self . parse_array_with_context (
906+ depth + 1 ,
907+ ArrayParseContext :: ListItemFirstField ,
908+ ) ?
866909 } else {
867910 self . advance ( ) ?;
868911 // Handle nested arrays: "key: [2]: ..."
869912 if matches ! ( self . current_token, Token :: LeftBracket ) {
913+ // Array after colon - not directly on hyphen line, use normal
914+ // context
870915 self . parse_array ( depth + 2 ) ?
871916 } else {
872917 self . parse_field_value ( depth + 2 ) ?
@@ -1430,4 +1475,186 @@ hello: 0(f)"#;
14301475 } )
14311476 ) ;
14321477 }
1478+
1479+ #[ test]
1480+ fn test_decode_list_item_tabular_array_v3 ( ) {
1481+ // Tabular arrays as first field of list items
1482+ // Rows must be at depth +2 relative to hyphen (6 spaces from root)
1483+ let input = r#"items[1]:
1484+ - users[2]{id,name}:
1485+ 1,Ada
1486+ 2,Bob
1487+ status: active"# ;
1488+
1489+ let result = parse ( input) . unwrap ( ) ;
1490+
1491+ assert_eq ! (
1492+ result,
1493+ json!( {
1494+ "items" : [
1495+ {
1496+ "users" : [
1497+ { "id" : 1 , "name" : "Ada" } ,
1498+ { "id" : 2 , "name" : "Bob" }
1499+ ] ,
1500+ "status" : "active"
1501+ }
1502+ ]
1503+ } )
1504+ ) ;
1505+ }
1506+
1507+ #[ test]
1508+ fn test_decode_list_item_tabular_array_multiple_items ( ) {
1509+ // Multiple list items each with tabular array as first field
1510+ let input = r#"data[2]:
1511+ - records[1]{id,val}:
1512+ 1,x
1513+ count: 1
1514+ - records[1]{id,val}:
1515+ 2,y
1516+ count: 1"# ;
1517+
1518+ let result = parse ( input) . unwrap ( ) ;
1519+
1520+ assert_eq ! (
1521+ result,
1522+ json!( {
1523+ "data" : [
1524+ {
1525+ "records" : [ { "id" : 1 , "val" : "x" } ] ,
1526+ "count" : 1
1527+ } ,
1528+ {
1529+ "records" : [ { "id" : 2 , "val" : "y" } ] ,
1530+ "count" : 1
1531+ }
1532+ ]
1533+ } )
1534+ ) ;
1535+ }
1536+
1537+ #[ test]
1538+ fn test_decode_list_item_tabular_array_with_multiple_fields ( ) {
1539+ // List item with tabular array first and multiple sibling fields
1540+ let input = r#"entries[1]:
1541+ - people[2]{name,age}:
1542+ Alice,30
1543+ Bob,25
1544+ total: 2
1545+ category: staff"# ;
1546+
1547+ let result = parse ( input) . unwrap ( ) ;
1548+
1549+ assert_eq ! (
1550+ result,
1551+ json!( {
1552+ "entries" : [
1553+ {
1554+ "people" : [
1555+ { "name" : "Alice" , "age" : 30 } ,
1556+ { "name" : "Bob" , "age" : 25 }
1557+ ] ,
1558+ "total" : 2 ,
1559+ "category" : "staff"
1560+ }
1561+ ]
1562+ } )
1563+ ) ;
1564+ }
1565+
1566+ #[ test]
1567+ fn test_decode_list_item_non_tabular_array_unchanged ( ) {
1568+ // Non-tabular arrays as first field should work normally
1569+ let input = r#"items[1]:
1570+ - tags[3]: a,b,c
1571+ name: test"# ;
1572+
1573+ let result = parse ( input) . unwrap ( ) ;
1574+
1575+ assert_eq ! (
1576+ result,
1577+ json!( {
1578+ "items" : [
1579+ {
1580+ "tags" : [ "a" , "b" , "c" ] ,
1581+ "name" : "test"
1582+ }
1583+ ]
1584+ } )
1585+ ) ;
1586+ }
1587+
1588+ #[ test]
1589+ fn test_decode_strict_rejects_v2_tabular_indent ( ) {
1590+ use crate :: decode:: decode_strict;
1591+
1592+ // Old format: rows at depth +1 (4 spaces from root)
1593+ // Strict mode should reject this incorrect indentation
1594+ let input_v2 = r#"items[1]:
1595+ - users[2]{id,name}:
1596+ 1,Ada
1597+ 2,Bob"# ;
1598+
1599+ let result = decode_strict :: < Value > ( input_v2) ;
1600+
1601+ // Should error due to incorrect indentation
1602+ assert ! (
1603+ result. is_err( ) ,
1604+ "Old format with incorrect indentation should be rejected in strict mode"
1605+ ) ;
1606+ let err_msg = result. unwrap_err ( ) . to_string ( ) ;
1607+ assert ! (
1608+ err_msg. contains( "indentation" ) || err_msg. contains( "Invalid indentation" ) ,
1609+ "Error should mention indentation. Got: {}" ,
1610+ err_msg
1611+ ) ;
1612+ }
1613+
1614+ #[ test]
1615+ fn test_decode_tabular_array_not_in_list_item_unchanged ( ) {
1616+ // Regular tabular arrays (not in list items) should still use depth +1
1617+ let input = r#"users[2]{id,name}:
1618+ 1,Ada
1619+ 2,Bob"# ;
1620+
1621+ let result = parse ( input) . unwrap ( ) ;
1622+
1623+ assert_eq ! (
1624+ result,
1625+ json!( {
1626+ "users" : [
1627+ { "id" : 1 , "name" : "Ada" } ,
1628+ { "id" : 2 , "name" : "Bob" }
1629+ ]
1630+ } )
1631+ ) ;
1632+ }
1633+
1634+ #[ test]
1635+ fn test_decode_nested_tabular_not_first_field ( ) {
1636+ // Tabular array as a subsequent field (not first) should use normal depth
1637+ let input = r#"items[1]:
1638+ - name: test
1639+ data[2]{id,val}:
1640+ 1,x
1641+ 2,y"# ;
1642+
1643+ let result = parse ( input) . unwrap ( ) ;
1644+
1645+ assert_eq ! (
1646+ result,
1647+ json!( {
1648+ "items" : [
1649+ {
1650+ "name" : "test" ,
1651+ "data" : [
1652+ { "id" : 1 , "val" : "x" } ,
1653+ { "id" : 2 , "val" : "y" }
1654+ ]
1655+ }
1656+ ]
1657+ } )
1658+ ) ;
1659+ }
14331660}
0 commit comments