Skip to content

Commit 554bd1e

Browse files
authored
feat: update to TOON v3.0 spec (breaking change) (#36)
* chore: new spec * feat: implement TOON spec v3.0 indentation rules for list-item arrays Breaking change: Arrays as first field of list-item objects now use depth +2 indentation (was +1). Affects tabular arrays, non-uniform arrays, and arrays of arrays in list-item context. * fix: cli flags
1 parent 360b5b7 commit 554bd1e

5 files changed

Lines changed: 542 additions & 14 deletions

File tree

src/cli/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ struct Cli {
7777
#[arg(long, value_parser = parse_delimiter, help = "Delimiter: comma, tab, or pipe")]
7878
delimiter: Option<Delimiter>,
7979

80-
#[arg(short, long, value_parser = parse_indent, help = "Indentation spaces")]
80+
#[arg(long, value_parser = parse_indent, help = "Indentation spaces")]
8181
indent: Option<usize>,
8282

8383
#[arg(long, help = "Disable strict validation (decode)")]

src/decode/parser.rs

Lines changed: 235 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
3247
pub 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

Comments
 (0)