toonify_core/
decoder.rs

1use std::io::Read;
2use std::str::FromStr;
3
4use serde_json::{Map, Number, Value};
5
6use crate::error::ToonifyError;
7use crate::options::{DecoderOptions, Delimiter, PathExpansionMode};
8use crate::quoting::is_identifier_segment;
9
10/// Decode TOON text into a serde_json::Value.
11pub fn decode_str(input: &str, options: DecoderOptions) -> Result<Value, ToonifyError> {
12    let mut decoder = Decoder::new(input, options)?;
13    let mut value = decoder.parse_root()?;
14
15    if matches!(decoder.options.expand_paths, PathExpansionMode::Safe) {
16        value = expand_paths(value, decoder.options.strict)?;
17    }
18
19    Ok(value)
20}
21
22/// Decode TOON from any reader.
23pub fn decode_reader<R: Read>(
24    mut reader: R,
25    options: DecoderOptions,
26) -> Result<Value, ToonifyError> {
27    let mut buf = String::new();
28    reader.read_to_string(&mut buf)?;
29    decode_str(&buf, options)
30}
31
32struct Decoder {
33    lines: Vec<Line>,
34    index: usize,
35    options: DecoderOptions,
36}
37
38#[derive(Clone, Debug)]
39struct Line {
40    depth: usize,
41    text: String,
42    number: usize,
43}
44
45impl Decoder {
46    fn new(input: &str, options: DecoderOptions) -> Result<Self, ToonifyError> {
47        let mut lines = Vec::new();
48        for (idx, raw) in input.lines().enumerate() {
49            let line_number = idx + 1;
50            if raw.trim().is_empty() {
51                continue;
52            }
53
54            let mut indent_chars = 0usize;
55            for ch in raw.chars() {
56                match ch {
57                    ' ' => indent_chars += 1,
58                    '\t' => {
59                        return Err(ToonifyError::decoding(format!(
60                            "line {line_number}: tabs are not allowed for indentation"
61                        )))
62                    }
63                    _ => break,
64                }
65            }
66
67            if indent_chars % options.indent != 0 {
68                return Err(ToonifyError::decoding(format!(
69                    "line {line_number}: indentation must be a multiple of {} spaces",
70                    options.indent
71                )));
72            }
73
74            let depth = indent_chars / options.indent;
75            let text = raw[indent_chars..].trim_end();
76            if text.is_empty() {
77                continue;
78            }
79
80            lines.push(Line {
81                depth,
82                text: text.to_string(),
83                number: line_number,
84            });
85        }
86
87        Ok(Self {
88            lines,
89            index: 0,
90            options,
91        })
92    }
93
94    fn parse_root(&mut self) -> Result<Value, ToonifyError> {
95        if self.lines.is_empty() {
96            return Ok(Value::Object(Map::new()));
97        }
98
99        if self.lines[0].text.starts_with('[') {
100            let header = self
101                .parse_header_for_line(&self.lines[0], false)?
102                .ok_or_else(|| {
103                    ToonifyError::decoding(format!(
104                        "line {}: expected array header",
105                        self.lines[0].number
106                    ))
107                })?;
108            self.index += 1;
109            return self.consume_array(header, 0);
110        }
111
112        if !self.lines[0].text.contains(':') {
113            let value = parse_primitive_token(self.lines[0].text.trim()).map_err(|err| {
114                ToonifyError::decoding(format!("line {}: {err}", self.lines[0].number))
115            })?;
116            self.index = self.lines.len();
117            return Ok(value);
118        }
119
120        let object = self.parse_object(0)?;
121        Ok(Value::Object(object))
122    }
123
124    fn parse_object(&mut self, depth: usize) -> Result<Map<String, Value>, ToonifyError> {
125        let mut map = Map::new();
126        while let Some(line) = self.peek_line().cloned() {
127            if line.depth != depth {
128                break;
129            }
130
131            if let Some(header) = self.try_parse_header(&line, true)? {
132                self.index += 1;
133                let key = header.key.clone().ok_or_else(|| {
134                    ToonifyError::decoding(format!(
135                        "line {}: array header requires a key",
136                        line.number
137                    ))
138                })?;
139                let value = self.consume_array(header, depth)?;
140                map.insert(key, value);
141                continue;
142            }
143
144            self.consume_field(&mut map, depth)?;
145        }
146        Ok(map)
147    }
148
149    fn consume_field(
150        &mut self,
151        map: &mut Map<String, Value>,
152        depth: usize,
153    ) -> Result<(), ToonifyError> {
154        let line = self
155            .peek_line()
156            .cloned()
157            .ok_or_else(|| ToonifyError::decoding("unexpected end of document"))?;
158
159        if let Some(header) = self.parse_header_for_line(&line, true)? {
160            self.index += 1;
161            let key = header.key.clone().ok_or_else(|| {
162                ToonifyError::decoding(format!("line {}: array header requires a key", line.number))
163            })?;
164            let value = self.consume_array(header, depth)?;
165            map.insert(key, value);
166            return Ok(());
167        }
168
169        let (raw_key, rest) = split_key_value(&line.text).ok_or_else(|| {
170            ToonifyError::decoding(format!("line {}: expected `key: value`", line.number))
171        })?;
172        let key = parse_key_token(raw_key)
173            .map_err(|err| ToonifyError::decoding(format!("line {}: {err}", line.number)))?;
174
175        self.index += 1;
176
177        if rest.trim().is_empty() {
178            // Nested structure
179            if let Some(next) = self.peek_line() {
180                if next.depth <= depth {
181                    map.insert(key, Value::Object(Map::new()));
182                    return Ok(());
183                }
184            } else {
185                map.insert(key, Value::Object(Map::new()));
186                return Ok(());
187            }
188
189            let value = self.parse_value_block(depth + 1)?;
190            map.insert(key, value);
191            return Ok(());
192        }
193
194        let value = parse_primitive_token(rest.trim())
195            .map_err(|err| ToonifyError::decoding(format!("line {}: {err}", line.number)))?;
196        map.insert(key, value);
197        Ok(())
198    }
199
200    fn parse_value_block(&mut self, depth: usize) -> Result<Value, ToonifyError> {
201        if let Some(line) = self.peek_line() {
202            if line.depth != depth {
203                return Ok(Value::Object(Map::new()));
204            }
205
206            if line.text.starts_with('[') {
207                let header = self.parse_header_for_line(line, false)?.ok_or_else(|| {
208                    ToonifyError::decoding(format!("line {}: expected array header", line.number))
209                })?;
210                self.index += 1;
211                return self.consume_array(header, depth - 1);
212            }
213
214            if split_key_value(&line.text).is_some() {
215                let object = self.parse_object(depth)?;
216                return Ok(Value::Object(object));
217            }
218
219            let value = parse_primitive_token(line.text.trim())
220                .map_err(|err| ToonifyError::decoding(format!("line {}: {err}", line.number)))?;
221            self.index += 1;
222            return Ok(value);
223        }
224
225        Ok(Value::Null)
226    }
227
228    fn try_parse_header(
229        &self,
230        line: &Line,
231        expect_key: bool,
232    ) -> Result<Option<ArrayHeader>, ToonifyError> {
233        if !line.text.contains('[') {
234            return Ok(None);
235        }
236
237        if let Some(header) = self.parse_header_for_line(line, expect_key)? {
238            return Ok(Some(header));
239        }
240
241        Ok(None)
242    }
243
244    fn parse_header_for_line(
245        &self,
246        line: &Line,
247        expect_key: bool,
248    ) -> Result<Option<ArrayHeader>, ToonifyError> {
249        parse_header(&line.text, expect_key, line.number)
250    }
251
252    fn consume_array(
253        &mut self,
254        header: ArrayHeader,
255        container_depth: usize,
256    ) -> Result<Value, ToonifyError> {
257        if let Some(inline) = header
258            .inline_values
259            .as_deref()
260            .filter(|value| !value.is_empty())
261        {
262            return self.parse_inline_array(header.len, header.delimiter, inline, header.line);
263        }
264
265        if header.fields.is_some() {
266            return self.parse_tabular_array(header, container_depth);
267        }
268
269        self.parse_list_array(header, container_depth)
270    }
271
272    fn parse_inline_array(
273        &self,
274        len: usize,
275        delimiter: Delimiter,
276        values: &str,
277        line: usize,
278    ) -> Result<Value, ToonifyError> {
279        let cells = split_delimited(values, delimiter)?;
280        if self.options.strict && cells.len() != len {
281            return Err(ToonifyError::decoding(format!(
282                "line {line}: expected {len} values but found {}",
283                cells.len()
284            )));
285        }
286
287        let mut out = Vec::with_capacity(cells.len());
288        for cell in cells {
289            let value = parse_primitive_token(cell.trim())
290                .map_err(|err| ToonifyError::decoding(format!("line {line}: {err}")))?;
291            out.push(value);
292        }
293        Ok(Value::Array(out))
294    }
295
296    fn parse_tabular_array(
297        &mut self,
298        header: ArrayHeader,
299        container_depth: usize,
300    ) -> Result<Value, ToonifyError> {
301        let fields = header.fields.clone().unwrap_or_default();
302        let row_depth = container_depth + 1;
303        let mut rows = Vec::new();
304
305        while let Some(line) = self.peek_line().cloned() {
306            if line.depth != row_depth {
307                break;
308            }
309
310            if !is_tabular_row_line(&line.text, header.delimiter) {
311                break;
312            }
313
314            let cells = split_delimited(&line.text, header.delimiter)?;
315            if self.options.strict && cells.len() != fields.len() {
316                return Err(ToonifyError::decoding(format!(
317                    "line {}: expected {} cells but found {}",
318                    line.number,
319                    fields.len(),
320                    cells.len()
321                )));
322            }
323
324            let mut map = Map::new();
325            for (idx, field) in fields.iter().enumerate() {
326                let cell = cells.get(idx).map(|s| s.trim()).unwrap_or("");
327                let value = parse_primitive_token(cell).map_err(|err| {
328                    ToonifyError::decoding(format!("line {}: {err}", line.number))
329                })?;
330                map.insert(field.clone(), value);
331            }
332
333            rows.push(Value::Object(map));
334            self.index += 1;
335        }
336
337        if self.options.strict && rows.len() != header.len {
338            return Err(ToonifyError::decoding(format!(
339                "line {}: expected {} rows but found {}",
340                header.line,
341                header.len,
342                rows.len()
343            )));
344        }
345
346        Ok(Value::Array(rows))
347    }
348
349    fn parse_list_array(
350        &mut self,
351        header: ArrayHeader,
352        container_depth: usize,
353    ) -> Result<Value, ToonifyError> {
354        let row_depth = container_depth + 1;
355        let mut items = Vec::new();
356
357        while let Some(line) = self.peek_line().cloned() {
358            if line.depth != row_depth {
359                break;
360            }
361
362            if !line.text.starts_with("- ") {
363                return Err(ToonifyError::decoding(format!(
364                    "line {}: expected '-' to start list item",
365                    line.number
366                )));
367            }
368
369            let remainder = line.text[2..].trim();
370            self.index += 1;
371
372            let value = if remainder.is_empty() {
373                let object = self.parse_object(row_depth + 1)?;
374                Value::Object(object)
375            } else if let Some(sub_header) = parse_header(remainder, false, line.number)? {
376                let key = sub_header.key.clone();
377                let value = self.consume_nested_header(sub_header, row_depth)?;
378                if let Some(key) = key {
379                    let mut map = Map::new();
380                    map.insert(key, value);
381                    while let Some(next) = self.peek_line() {
382                        if next.depth != row_depth + 1 {
383                            break;
384                        }
385                        self.consume_field(&mut map, row_depth + 1)?;
386                    }
387                    Value::Object(map)
388                } else {
389                    value
390                }
391            } else if remainder.contains(':') {
392                self.parse_inline_object_in_list(remainder, row_depth, line.number)?
393            } else {
394                parse_primitive_token(remainder)
395                    .map_err(|err| ToonifyError::decoding(format!("line {}: {err}", line.number)))?
396            };
397
398            items.push(value);
399        }
400
401        if self.options.strict && items.len() != header.len {
402            return Err(ToonifyError::decoding(format!(
403                "line {}: expected {} list items but found {}",
404                header.line,
405                header.len,
406                items.len()
407            )));
408        }
409
410        Ok(Value::Array(items))
411    }
412
413    fn consume_nested_header(
414        &mut self,
415        mut header: ArrayHeader,
416        row_depth: usize,
417    ) -> Result<Value, ToonifyError> {
418        // The header was parsed from an inline string, so do not advance index again.
419        header.key = None;
420        self.consume_array(header, row_depth)
421    }
422
423    fn parse_inline_object_in_list(
424        &mut self,
425        inline: &str,
426        row_depth: usize,
427        line_number: usize,
428    ) -> Result<Value, ToonifyError> {
429        let (raw_key, rest) = split_key_value(inline).ok_or_else(|| {
430            ToonifyError::decoding(format!("line {line_number}: invalid list object syntax"))
431        })?;
432        let key = parse_key_token(raw_key)
433            .map_err(|err| ToonifyError::decoding(format!("line {line_number}: {err}")))?;
434
435        let mut map = Map::new();
436        if rest.trim().is_empty() {
437            let value = self.parse_value_block(row_depth + 2)?;
438            map.insert(key, value);
439        } else {
440            let value = parse_primitive_token(rest.trim())
441                .map_err(|err| ToonifyError::decoding(format!("line {line_number}: {err}")))?;
442            map.insert(key, value);
443        }
444
445        while let Some(next) = self.peek_line() {
446            if next.depth != row_depth + 1 {
447                break;
448            }
449            self.consume_field(&mut map, row_depth + 1)?;
450        }
451
452        Ok(Value::Object(map))
453    }
454
455    fn peek_line(&self) -> Option<&Line> {
456        self.lines.get(self.index)
457    }
458}
459
460#[derive(Clone, Debug)]
461struct ArrayHeader {
462    key: Option<String>,
463    len: usize,
464    delimiter: Delimiter,
465    fields: Option<Vec<String>>,
466    inline_values: Option<String>,
467    line: usize,
468}
469
470fn parse_header(
471    text: &str,
472    expect_key: bool,
473    line: usize,
474) -> Result<Option<ArrayHeader>, ToonifyError> {
475    let colon_idx = match text.find(':') {
476        Some(idx) => idx,
477        None => return Ok(None),
478    };
479
480    let before = text[..colon_idx].trim_end();
481    let after = text[colon_idx + 1..].trim_start();
482
483    if !before.contains('[') {
484        return Ok(None);
485    }
486
487    let bracket_idx = before
488        .rfind('[')
489        .ok_or_else(|| ToonifyError::decoding(format!("line {line}: malformed array header")))?;
490
491    let (raw_key, bracket_part) = if bracket_idx == 0 {
492        (None, before)
493    } else {
494        let key_text = before[..bracket_idx].trim_end();
495        let key = parse_key_token(key_text)
496            .map_err(|err| ToonifyError::decoding(format!("line {line}: {err}")))?;
497        (Some(key), &before[bracket_idx..])
498    };
499
500    if expect_key && raw_key.is_none() {
501        return Err(ToonifyError::decoding(format!(
502            "line {line}: array header must include a key"
503        )));
504    }
505
506    let closing = bracket_part
507        .find(']')
508        .ok_or_else(|| ToonifyError::decoding(format!("line {line}: missing closing ']'")))?;
509
510    let mut bracket_inner = bracket_part[1..closing].trim();
511    let delimiter = if bracket_inner.ends_with('|') {
512        bracket_inner = &bracket_inner[..bracket_inner.len() - 1];
513        Delimiter::Pipe
514    } else if bracket_inner.ends_with('\t') {
515        bracket_inner = &bracket_inner[..bracket_inner.len() - 1];
516        Delimiter::Tab
517    } else {
518        Delimiter::Comma
519    };
520
521    let len: usize = bracket_inner
522        .parse()
523        .map_err(|_| ToonifyError::decoding(format!("line {line}: invalid array length")))?;
524
525    let mut remainder = bracket_part[closing + 1..].trim_start();
526    let fields = if remainder.starts_with('{') {
527        let closing_brace = remainder.find('}').ok_or_else(|| {
528            ToonifyError::decoding(format!("line {line}: missing '}}' in field list"))
529        })?;
530        let field_segment = &remainder[1..closing_brace];
531        let list = parse_field_list(field_segment, delimiter)?;
532        remainder = remainder[closing_brace + 1..].trim_start();
533        Some(list)
534    } else {
535        None
536    };
537
538    if !remainder.is_empty() {
539        return Err(ToonifyError::decoding(format!(
540            "line {line}: unexpected content after array header"
541        )));
542    }
543
544    Ok(Some(ArrayHeader {
545        key: raw_key,
546        len,
547        delimiter,
548        fields,
549        inline_values: if after.is_empty() {
550            None
551        } else {
552            Some(after.to_string())
553        },
554        line,
555    }))
556}
557
558fn parse_field_list(segment: &str, delimiter: Delimiter) -> Result<Vec<String>, ToonifyError> {
559    let mut fields = Vec::new();
560    for raw in split_delimited(segment, delimiter)? {
561        let key = parse_key_token(raw.trim())
562            .map_err(|err| ToonifyError::decoding(format!("invalid field name: {err}")))?;
563        fields.push(key);
564    }
565    Ok(fields)
566}
567
568fn split_key_value(text: &str) -> Option<(&str, &str)> {
569    let mut in_quotes = false;
570    let mut escaped = false;
571    for (idx, ch) in text.char_indices() {
572        match ch {
573            '"' if !escaped => in_quotes = !in_quotes,
574            '\\' if in_quotes => {
575                escaped = !escaped;
576                continue;
577            }
578            ':' if !in_quotes => {
579                let key = text[..idx].trim_end();
580                let value = text[idx + 1..].trim_start();
581                return Some((key, value));
582            }
583            _ => {}
584        }
585        escaped = false;
586    }
587    None
588}
589
590fn parse_key_token(raw: &str) -> Result<String, String> {
591    if raw.starts_with('"') {
592        return parse_quoted_string(raw);
593    }
594    if raw.is_empty() {
595        return Err("key cannot be empty".into());
596    }
597    Ok(raw.to_string())
598}
599
600fn parse_quoted_string(raw: &str) -> Result<String, String> {
601    if !raw.ends_with('"') {
602        return Err("unterminated string".into());
603    }
604    let inner = &raw[1..raw.len() - 1];
605    let mut chars = inner.chars();
606    let mut out = String::with_capacity(inner.len());
607    while let Some(ch) = chars.next() {
608        if ch == '\\' {
609            let escaped = chars
610                .next()
611                .ok_or_else(|| "unterminated escape".to_string())?;
612            match escaped {
613                '\\' => out.push('\\'),
614                '"' => out.push('"'),
615                'n' => out.push('\n'),
616                'r' => out.push('\r'),
617                't' => out.push('\t'),
618                other => {
619                    return Err(format!("unsupported escape \\{other}"));
620                }
621            }
622        } else {
623            out.push(ch);
624        }
625    }
626    Ok(out)
627}
628
629fn parse_primitive_token(token: &str) -> Result<Value, String> {
630    if token.starts_with('"') {
631        return parse_quoted_string(token).map(Value::String);
632    }
633
634    match token {
635        "true" => return Ok(Value::Bool(true)),
636        "false" => return Ok(Value::Bool(false)),
637        "null" => return Ok(Value::Null),
638        _ => {}
639    }
640
641    if is_numeric_literal(token) {
642        let number = Number::from_str(token).map_err(|_| "invalid number literal".to_string())?;
643        return Ok(Value::Number(number));
644    }
645
646    Ok(Value::String(token.to_string()))
647}
648
649fn is_numeric_literal(token: &str) -> bool {
650    if token.is_empty() {
651        return false;
652    }
653    if token.starts_with('0') && token.len() > 1 && token.chars().all(|c| c.is_ascii_digit()) {
654        return false;
655    }
656    Number::from_str(token).is_ok()
657}
658
659fn split_delimited(input: &str, delimiter: Delimiter) -> Result<Vec<String>, ToonifyError> {
660    let separator = delimiter.as_char();
661    let mut values = Vec::new();
662    let mut current = String::new();
663    let mut in_quotes = false;
664    let mut chars = input.chars().peekable();
665    while let Some(ch) = chars.next() {
666        match ch {
667            '"' => {
668                current.push(ch);
669                in_quotes = !in_quotes;
670            }
671            '\\' if in_quotes => {
672                current.push(ch);
673                if let Some(next) = chars.next() {
674                    current.push(next);
675                }
676            }
677            _ if !in_quotes && ch == separator => {
678                values.push(current.trim().to_string());
679                current.clear();
680            }
681            _ => current.push(ch),
682        }
683    }
684    values.push(current.trim().to_string());
685    Ok(values)
686}
687
688fn is_tabular_row_line(text: &str, delimiter: Delimiter) -> bool {
689    let mut first_delim = None;
690    let mut first_colon = None;
691    let mut in_quotes = false;
692    let mut escaped = false;
693    let separator = delimiter.as_char();
694
695    for (idx, ch) in text.char_indices() {
696        if in_quotes {
697            if escaped {
698                escaped = false;
699                continue;
700            }
701            match ch {
702                '\\' => {
703                    escaped = true;
704                }
705                '"' => in_quotes = false,
706                _ => {}
707            }
708            continue;
709        }
710
711        match ch {
712            '"' => in_quotes = true,
713            ':' => {
714                if first_colon.is_none() {
715                    first_colon = Some(idx);
716                }
717            }
718            other if other == separator => {
719                if first_delim.is_none() {
720                    first_delim = Some(idx);
721                }
722            }
723            _ => {}
724        }
725
726        if first_delim.is_some() && first_colon.is_some() {
727            break;
728        }
729    }
730
731    match (first_delim, first_colon) {
732        (None, None) => true,
733        (None, Some(_)) => false,
734        (Some(_), None) => true,
735        (Some(delim_idx), Some(colon_idx)) => delim_idx < colon_idx,
736    }
737}
738
739fn expand_paths(value: Value, strict: bool) -> Result<Value, ToonifyError> {
740    match value {
741        Value::Object(map) => {
742            let mut replacement = Map::new();
743            for (key, val) in map {
744                let val = expand_paths(val, strict)?;
745                if key.contains('.') && key.split('.').all(is_identifier_segment) {
746                    insert_expanded(&mut replacement, &key, val, strict)?;
747                } else {
748                    replacement.insert(key, val);
749                }
750            }
751            Ok(Value::Object(replacement))
752        }
753        Value::Array(items) => {
754            let mut out = Vec::with_capacity(items.len());
755            for item in items {
756                out.push(expand_paths(item, strict)?);
757            }
758            Ok(Value::Array(out))
759        }
760        other => Ok(other),
761    }
762}
763
764fn insert_expanded(
765    target: &mut Map<String, Value>,
766    dotted: &str,
767    value: Value,
768    strict: bool,
769) -> Result<(), ToonifyError> {
770    let segments: Vec<&str> = dotted.split('.').collect();
771    if segments.is_empty() {
772        return Ok(());
773    }
774    insert_segments(target, &segments, value, strict, dotted)
775}
776
777fn insert_segments(
778    current: &mut Map<String, Value>,
779    segments: &[&str],
780    value: Value,
781    strict: bool,
782    full_key: &str,
783) -> Result<(), ToonifyError> {
784    if segments.len() == 1 {
785        match current.get_mut(segments[0]) {
786            Some(existing) => {
787                if strict {
788                    return Err(ToonifyError::decoding(format!(
789                        "expansion conflict at '{full_key}'"
790                    )));
791                }
792                *existing = value;
793            }
794            None => {
795                current.insert(segments[0].to_string(), value);
796            }
797        }
798        return Ok(());
799    }
800
801    let entry = current
802        .entry(segments[0].to_string())
803        .or_insert_with(|| Value::Object(Map::new()));
804
805    match entry {
806        Value::Object(map) => insert_segments(map, &segments[1..], value, strict, full_key),
807        other => {
808            if strict {
809                Err(ToonifyError::decoding(format!(
810                    "expansion conflict at '{full_key}': expected object but found {other:?}"
811                )))
812            } else {
813                *other = Value::Object(Map::new());
814                if let Value::Object(map) = other {
815                    insert_segments(map, &segments[1..], value, strict, full_key)
816                } else {
817                    unreachable!()
818                }
819            }
820        }
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use serde_json::json;
828
829    #[test]
830    fn decodes_list_item_with_nested_object_first_field() {
831        let doc = r#"items[1]:
832  - user:
833      name: Ada
834      email: ada@example.com
835    role: admin
836"#;
837
838        let value = decode_str(doc, DecoderOptions::default()).unwrap();
839        let expected = json!({
840            "items": [
841                {
842                    "user": {
843                        "name": "Ada",
844                        "email": "ada@example.com"
845                    },
846                    "role": "admin"
847                }
848            ]
849        });
850        assert_eq!(value, expected);
851    }
852
853    #[test]
854    fn decodes_tabular_array_on_hyphen_line_and_resumes_fields() {
855        let doc = r#"groups[1]:
856  - members[2]{id,name}:
857    1,Ada
858    2,Bob
859    status: active
860"#;
861
862        let value = decode_str(doc, DecoderOptions::default()).unwrap();
863        let expected = json!({
864            "groups": [
865                {
866                    "members": [
867                        { "id": 1, "name": "Ada" },
868                        { "id": 2, "name": "Bob" }
869                    ],
870                    "status": "active"
871                }
872            ]
873        });
874        assert_eq!(value, expected);
875    }
876
877    #[test]
878    fn decodes_inline_array_field_inside_object() {
879        let doc = r#"form:
880  op[2]: readproperty,writeproperty
881"#;
882
883        let value = decode_str(doc, DecoderOptions::default()).unwrap();
884        let expected = json!({
885            "form": {
886                "op": ["readproperty", "writeproperty"]
887            }
888        });
889        assert_eq!(value, expected);
890    }
891}