Skip to main content

panache_parser/parser/
yaml.rs

1//! YAML parser groundwork for long-term Panache integration.
2//!
3//! This module is intentionally minimal and currently acts as a placeholder for a
4//! future in-tree YAML parser that can produce Panache-compatible CST structures.
5//! Initial goals:
6//! - support plain YAML and hashpipe-prefixed YAML from shared parsing primitives,
7//! - preserve lossless syntax/trivia needed for exact host document ranges,
8//! - enable shadow-mode comparison against the existing YAML engine before rollout.
9//! - prepare for first-class YAML formatting support once parser parity is proven.
10
11#[path = "yaml/lexer.rs"]
12mod lexer;
13#[path = "yaml/model.rs"]
14mod model;
15#[path = "yaml/parser.rs"]
16mod parser;
17
18pub use lexer::lex_mapping_tokens;
19pub use model::{
20    ShadowYamlOptions, ShadowYamlOutcome, ShadowYamlReport, YamlDiagnostic, YamlInputKind,
21    YamlParseReport, YamlToken, YamlTokenSpan, diagnostic_codes,
22};
23pub use parser::{parse_shadow, parse_yaml_report, parse_yaml_tree};
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28    use crate::syntax::SyntaxKind;
29
30    #[test]
31    fn builds_basic_rowan_tree_for_multiline_mapping() {
32        let tree = parse_yaml_tree("title: My Title\nauthor: Me\n").expect("tree");
33        assert_eq!(tree.kind(), SyntaxKind::DOCUMENT);
34        assert_eq!(tree.text().to_string(), "title: My Title\nauthor: Me\n");
35
36        let content = tree
37            .children()
38            .find(|n| n.kind() == SyntaxKind::YAML_METADATA_CONTENT)
39            .expect("yaml metadata content");
40        let mapping = content
41            .children()
42            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
43            .expect("yaml block map");
44        let entries: Vec<_> = mapping
45            .children()
46            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
47            .collect();
48        assert_eq!(entries.len(), 2);
49
50        let token_kinds: Vec<_> = mapping
51            .descendants_with_tokens()
52            .filter_map(|el| el.into_token())
53            .map(|tok| tok.kind())
54            .collect();
55        assert_eq!(
56            token_kinds,
57            vec![
58                SyntaxKind::YAML_KEY,
59                SyntaxKind::YAML_COLON,
60                SyntaxKind::WHITESPACE,
61                SyntaxKind::YAML_SCALAR,
62                SyntaxKind::NEWLINE,
63                SyntaxKind::YAML_KEY,
64                SyntaxKind::YAML_COLON,
65                SyntaxKind::WHITESPACE,
66                SyntaxKind::YAML_SCALAR,
67                SyntaxKind::NEWLINE,
68            ]
69        );
70    }
71
72    #[test]
73    fn mapping_nodes_preserve_entry_text_boundaries() {
74        let tree = parse_yaml_tree("title: A\nauthor: B\n").expect("tree");
75        let content = tree
76            .children()
77            .find(|n| n.kind() == SyntaxKind::YAML_METADATA_CONTENT)
78            .expect("yaml metadata content");
79        let mapping = content
80            .children()
81            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
82            .expect("yaml block map");
83
84        let entry_texts: Vec<_> = mapping
85            .children()
86            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
87            .map(|n| n.text().to_string())
88            .collect();
89        assert_eq!(
90            entry_texts,
91            vec!["title: A\n".to_string(), "author: B\n".to_string(),]
92        );
93    }
94
95    #[test]
96    fn splits_mapping_on_colon_outside_quoted_key() {
97        let input = "\"foo:bar\": 23\n'x:y': 24\n";
98        let tree = parse_yaml_tree(input).expect("tree");
99        assert_eq!(tree.text().to_string(), input);
100
101        let keys: Vec<String> = tree
102            .descendants_with_tokens()
103            .filter_map(|el| el.into_token())
104            .filter(|tok| tok.kind() == SyntaxKind::YAML_KEY)
105            .map(|tok| tok.text().to_string())
106            .collect();
107        assert_eq!(keys, vec!["\"foo:bar\"".to_string(), "'x:y'".to_string()]);
108    }
109
110    #[test]
111    fn splits_mapping_on_colon_outside_flow_key() {
112        let input = "{a: b}: 23\n";
113        let tree = parse_yaml_tree(input).expect("tree");
114        assert_eq!(tree.text().to_string(), input);
115
116        let keys: Vec<String> = tree
117            .descendants_with_tokens()
118            .filter_map(|el| el.into_token())
119            .filter(|tok| tok.kind() == SyntaxKind::YAML_KEY)
120            .map(|tok| tok.text().to_string())
121            .collect();
122        assert_eq!(keys, vec!["{a: b}".to_string()]);
123    }
124
125    #[test]
126    fn keeps_colon_inside_escaped_double_quoted_key() {
127        let input = "\"foo\\\":bar\": 23\n";
128        let tree = parse_yaml_tree(input).expect("tree");
129        assert_eq!(tree.text().to_string(), input);
130
131        let keys: Vec<String> = tree
132            .descendants_with_tokens()
133            .filter_map(|el| el.into_token())
134            .filter(|tok| tok.kind() == SyntaxKind::YAML_KEY)
135            .map(|tok| tok.text().to_string())
136            .collect();
137        assert_eq!(keys, vec!["\"foo\\\":bar\"".to_string()]);
138    }
139
140    #[test]
141    fn keeps_hash_in_double_quoted_scalar_value() {
142        let input = "foo: \"a#b\"\n";
143        let tree = parse_yaml_tree(input).expect("tree");
144
145        let comment_count = tree
146            .descendants_with_tokens()
147            .filter_map(|el| el.into_token())
148            .filter(|tok| tok.kind() == SyntaxKind::YAML_COMMENT)
149            .count();
150        assert_eq!(comment_count, 0);
151
152        let scalar_values: Vec<String> = tree
153            .descendants_with_tokens()
154            .filter_map(|el| el.into_token())
155            .filter(|tok| tok.kind() == SyntaxKind::YAML_SCALAR)
156            .map(|tok| tok.text().to_string())
157            .collect();
158        assert_eq!(scalar_values, vec!["\"a#b\"".to_string()]);
159    }
160
161    #[test]
162    fn keeps_colon_inside_single_quoted_key_with_escaped_quote() {
163        let input = "'foo'':bar': 23\n";
164        let tree = parse_yaml_tree(input).expect("tree");
165        assert_eq!(tree.text().to_string(), input);
166
167        let keys: Vec<String> = tree
168            .descendants_with_tokens()
169            .filter_map(|el| el.into_token())
170            .filter(|tok| tok.kind() == SyntaxKind::YAML_KEY)
171            .map(|tok| tok.text().to_string())
172            .collect();
173        assert_eq!(keys, vec!["'foo'':bar'".to_string()]);
174    }
175
176    #[test]
177    fn preserves_explicit_tag_tokens_in_key_and_value() {
178        let input = "!!str a: !!int 42\n";
179        let tree = parse_yaml_tree(input).expect("tree");
180        assert_eq!(tree.text().to_string(), input);
181
182        let tag_tokens: Vec<_> = tree
183            .descendants_with_tokens()
184            .filter_map(|el| el.into_token())
185            .filter(|tok| tok.kind() == SyntaxKind::YAML_TAG)
186            .map(|tok| tok.text().to_string())
187            .collect();
188        assert_eq!(tag_tokens, vec!["!!str".to_string(), "!!int".to_string()]);
189    }
190
191    #[test]
192    fn lexer_emits_tokens_for_quoted_keys_and_inline_comments() {
193        let input = "\"foo:bar\": 23 # note\n'x:y': 'z' # ok\n";
194        let tokens = lex_mapping_tokens(input).expect("tokens");
195        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
196        assert_eq!(
197            kinds,
198            vec![
199                YamlToken::Key,
200                YamlToken::Colon,
201                YamlToken::Whitespace,
202                YamlToken::Scalar,
203                YamlToken::Whitespace,
204                YamlToken::Comment,
205                YamlToken::Newline,
206                YamlToken::Key,
207                YamlToken::Colon,
208                YamlToken::Whitespace,
209                YamlToken::Scalar,
210                YamlToken::Whitespace,
211                YamlToken::Comment,
212                YamlToken::Newline,
213            ]
214        );
215        let comments: Vec<_> = tokens
216            .iter()
217            .filter(|t| t.kind == YamlToken::Comment)
218            .map(|t| t.text)
219            .collect();
220        assert_eq!(comments, vec!["# note", "# ok"]);
221    }
222
223    #[test]
224    fn lexer_emits_indent_and_dedent_for_indented_entries() {
225        let input = "root: 1\n  child: 2\n";
226        let tokens = lex_mapping_tokens(input).expect("tokens");
227        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
228        assert!(kinds.contains(&YamlToken::Indent));
229        assert!(kinds.contains(&YamlToken::Dedent));
230    }
231
232    #[test]
233    fn lexer_emits_document_start_marker_token() {
234        let input = "---\n";
235        let tokens = lex_mapping_tokens(input).expect("tokens");
236        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
237        assert_eq!(kinds, vec![YamlToken::DocumentStart, YamlToken::Newline,]);
238    }
239
240    #[test]
241    fn lexer_emits_flow_tokens_for_standalone_flow_mapping() {
242        let input = "{foo: bar}\n";
243        let tokens = lex_mapping_tokens(input).expect("tokens");
244        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
245        assert_eq!(
246            kinds,
247            vec![
248                YamlToken::FlowMapStart,
249                YamlToken::Scalar,
250                YamlToken::FlowMapEnd,
251                YamlToken::Newline,
252            ]
253        );
254    }
255
256    #[test]
257    fn lexer_emits_flow_sequence_tokens_in_mapping_value() {
258        let input = "a: [b, c]\n";
259        let tokens = lex_mapping_tokens(input).expect("tokens");
260        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
261        assert_eq!(
262            kinds,
263            vec![
264                YamlToken::Key,
265                YamlToken::Colon,
266                YamlToken::Whitespace,
267                YamlToken::FlowSeqStart,
268                YamlToken::Scalar,
269                YamlToken::Comma,
270                YamlToken::Scalar,
271                YamlToken::FlowSeqEnd,
272                YamlToken::Newline,
273            ]
274        );
275    }
276
277    #[test]
278    fn lexer_tokens_round_trip_input_bytes_for_supported_cases() {
279        let cases = [
280            "foo: bar\n",
281            "a: [b, c]\n",
282            "---\nfoo: bar\n...\n",
283            "%YAML 1.2\nfoo: \"a#b\"\n",
284        ];
285
286        for input in cases {
287            let tokens = lex_mapping_tokens(input).expect("tokens");
288            let rebuilt = tokens.iter().map(|t| t.text).collect::<String>();
289            assert_eq!(rebuilt, input);
290        }
291    }
292
293    #[test]
294    fn lexer_emits_monotonic_byte_ranges() {
295        let input = "root: 1\n  child: 2\n";
296        let tokens = lex_mapping_tokens(input).expect("tokens");
297
298        let mut offset = 0usize;
299        for token in tokens {
300            if token.text.is_empty() {
301                assert_eq!(token.byte_start, offset);
302                assert_eq!(token.byte_end, offset);
303                continue;
304            }
305
306            assert_eq!(token.byte_start, offset);
307            assert_eq!(&input[token.byte_start..token.byte_end], token.text);
308            offset = token.byte_end;
309        }
310
311        assert_eq!(offset, input.len());
312    }
313
314    #[test]
315    fn parser_preserves_document_markers_and_directives() {
316        let input = "%YAML 1.2\n---\nfoo: bar\n...\n";
317        let tree = parse_yaml_tree(input).expect("tree");
318        assert_eq!(tree.text().to_string(), input);
319
320        let scalar_tokens: Vec<String> = tree
321            .descendants_with_tokens()
322            .filter_map(|el| el.into_token())
323            .filter(|tok| tok.kind() == SyntaxKind::YAML_SCALAR)
324            .map(|tok| tok.text().to_string())
325            .collect();
326
327        assert!(scalar_tokens.contains(&"%YAML 1.2".to_string()));
328        assert!(scalar_tokens.contains(&"bar".to_string()));
329
330        let has_doc_start = tree
331            .descendants_with_tokens()
332            .filter_map(|el| el.into_token())
333            .any(|tok| tok.kind() == SyntaxKind::YAML_DOCUMENT_START && tok.text() == "---");
334        assert!(has_doc_start, "--- should be a YAML_DOCUMENT_START token");
335
336        let has_doc_end = tree
337            .descendants_with_tokens()
338            .filter_map(|el| el.into_token())
339            .any(|tok| tok.kind() == SyntaxKind::YAML_DOCUMENT_END && tok.text() == "...");
340        assert!(has_doc_end, "... should be a YAML_DOCUMENT_END token");
341    }
342
343    #[test]
344    fn parser_preserves_standalone_flow_mapping_lines() {
345        let input = "{foo: bar}\n";
346        let tree = parse_yaml_tree(input).expect("tree");
347        assert_eq!(tree.text().to_string(), input);
348
349        let flow_entry_count = tree
350            .descendants()
351            .filter(|n| n.kind() == SyntaxKind::YAML_FLOW_MAP_ENTRY)
352            .count();
353        assert_eq!(flow_entry_count, 1);
354
355        let flow_values: Vec<String> = tree
356            .descendants()
357            .filter(|n| n.kind() == SyntaxKind::YAML_FLOW_MAP_VALUE)
358            .map(|n| n.text().to_string())
359            .collect();
360        assert_eq!(flow_values, vec![" bar".to_string()]);
361    }
362
363    #[test]
364    fn parser_preserves_top_level_quoted_scalar_document() {
365        let input = "\"foo: bar\\\": baz\"\n";
366        let tree = parse_yaml_tree(input).expect("tree");
367        assert_eq!(tree.text().to_string(), input);
368    }
369
370    #[test]
371    fn parse_yaml_report_emits_error_code_for_invalid_yaml() {
372        let report = parse_yaml_report("this\n is\n  invalid: x\n");
373        assert!(report.tree.is_none());
374        assert_eq!(report.diagnostics.len(), 1);
375        assert_eq!(
376            report.diagnostics[0].code,
377            diagnostic_codes::PARSE_UNEXPECTED_INDENT
378        );
379    }
380
381    #[test]
382    fn parse_yaml_report_detects_trailing_content_after_document_end() {
383        let report = parse_yaml_report("---\nkey: value\n... invalid\n");
384        assert!(report.tree.is_none());
385        assert_eq!(report.diagnostics.len(), 1);
386        assert_eq!(
387            report.diagnostics[0].code,
388            diagnostic_codes::LEX_TRAILING_CONTENT_AFTER_DOCUMENT_END
389        );
390    }
391
392    #[test]
393    fn parse_yaml_report_detects_unexpected_flow_closer() {
394        let report = parse_yaml_report("---\n[ a, b, c ] ]\n");
395        assert!(report.tree.is_none());
396        assert_eq!(report.diagnostics.len(), 1);
397        assert_eq!(
398            report.diagnostics[0].code,
399            diagnostic_codes::PARSE_TRAILING_CONTENT_AFTER_FLOW_END
400        );
401    }
402
403    #[test]
404    fn parse_yaml_report_detects_unterminated_nested_flow_sequence() {
405        let report = parse_yaml_report("---\n[ [ a, b, c ]\n");
406        assert!(report.tree.is_none());
407        assert_eq!(report.diagnostics.len(), 1);
408        assert_eq!(
409            report.diagnostics[0].code,
410            diagnostic_codes::PARSE_UNTERMINATED_FLOW_SEQUENCE
411        );
412    }
413
414    #[test]
415    fn parse_yaml_report_detects_invalid_leading_flow_sequence_comma() {
416        let report = parse_yaml_report("---\n[ , a, b, c ]\n");
417        assert!(report.tree.is_none());
418        assert_eq!(report.diagnostics.len(), 1);
419        assert_eq!(
420            report.diagnostics[0].code,
421            diagnostic_codes::PARSE_INVALID_FLOW_SEQUENCE_COMMA
422        );
423    }
424
425    #[test]
426    fn parse_yaml_report_detects_trailing_content_after_flow_end() {
427        let report = parse_yaml_report("---\n[ a, b, c, ]#invalid\n");
428        assert!(report.tree.is_none());
429        assert_eq!(report.diagnostics.len(), 1);
430        assert_eq!(
431            report.diagnostics[0].code,
432            diagnostic_codes::PARSE_TRAILING_CONTENT_AFTER_FLOW_END
433        );
434    }
435
436    #[test]
437    fn parse_yaml_report_detects_invalid_double_quoted_escape() {
438        let report = parse_yaml_report("---\n\"\\.\"\n");
439        assert!(report.tree.is_none());
440        assert_eq!(report.diagnostics.len(), 1);
441        assert_eq!(
442            report.diagnostics[0].code,
443            diagnostic_codes::LEX_INVALID_DOUBLE_QUOTED_ESCAPE
444        );
445    }
446
447    #[test]
448    fn parse_yaml_report_detects_trailing_content_after_document_start() {
449        let report = parse_yaml_report("--- key1: value1\n    key2: value2\n");
450        assert!(report.tree.is_none());
451        assert_eq!(report.diagnostics.len(), 1);
452        assert_eq!(
453            report.diagnostics[0].code,
454            diagnostic_codes::LEX_TRAILING_CONTENT_AFTER_DOCUMENT_START
455        );
456    }
457
458    #[test]
459    fn parse_yaml_report_detects_directive_without_document_start() {
460        let report = parse_yaml_report("%YAML 1.2\n");
461        assert!(report.tree.is_none());
462        assert_eq!(report.diagnostics.len(), 1);
463        assert_eq!(
464            report.diagnostics[0].code,
465            diagnostic_codes::PARSE_DIRECTIVE_WITHOUT_DOCUMENT_START
466        );
467    }
468
469    #[test]
470    fn parse_yaml_report_detects_directive_after_content() {
471        let report = parse_yaml_report("!foo \"bar\"\n%TAG ! tag:example.com,2000:app/\n---\n");
472        assert!(report.tree.is_none());
473        assert_eq!(report.diagnostics.len(), 1);
474        assert_eq!(
475            report.diagnostics[0].code,
476            diagnostic_codes::PARSE_DIRECTIVE_AFTER_CONTENT
477        );
478    }
479
480    #[test]
481    fn parse_yaml_report_detects_wrong_indented_flow_continuation() {
482        let report = parse_yaml_report("---\nflow: [a,\nb,\nc]\n");
483        assert!(report.tree.is_none());
484        assert_eq!(report.diagnostics.len(), 1);
485        assert_eq!(
486            report.diagnostics[0].code,
487            diagnostic_codes::LEX_WRONG_INDENTED_FLOW
488        );
489    }
490
491    #[test]
492    fn parser_builds_flow_sequence_nodes_in_mapping_value() {
493        let input = "a: [b, c]\n";
494        let tree = parse_yaml_tree(input).expect("tree");
495        assert_eq!(tree.text().to_string(), input);
496
497        let seq = tree
498            .descendants()
499            .find(|n| n.kind() == SyntaxKind::YAML_FLOW_SEQUENCE)
500            .expect("flow sequence node");
501        let item_count = seq
502            .children()
503            .filter(|n| n.kind() == SyntaxKind::YAML_FLOW_SEQUENCE_ITEM)
504            .count();
505        assert_eq!(item_count, 2);
506    }
507
508    #[test]
509    fn parser_builds_multiline_flow_map_inside_block_sequence_item() {
510        let input = "- { multi\n  line, a: b}\n";
511        let tree = parse_yaml_tree(input).expect("tree");
512        assert_eq!(tree.text().to_string(), input);
513
514        let seq = tree
515            .descendants()
516            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE)
517            .expect("block sequence");
518        let item = seq
519            .children()
520            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE_ITEM)
521            .expect("sequence item");
522        let flow_map = item
523            .children()
524            .find(|n| n.kind() == SyntaxKind::YAML_FLOW_MAP)
525            .expect("flow map inside sequence item");
526        let entry_count = flow_map
527            .children()
528            .filter(|n| n.kind() == SyntaxKind::YAML_FLOW_MAP_ENTRY)
529            .count();
530        assert_eq!(entry_count, 2);
531    }
532
533    #[test]
534    fn parser_builds_flow_sequence_inside_block_sequence_item() {
535        let input = "- [a, b]\n- [c, d]\n";
536        let tree = parse_yaml_tree(input).expect("tree");
537        assert_eq!(tree.text().to_string(), input);
538
539        let seq = tree
540            .descendants()
541            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE)
542            .expect("block sequence");
543        let items: Vec<_> = seq
544            .children()
545            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE_ITEM)
546            .collect();
547        assert_eq!(items.len(), 2);
548
549        for item in &items {
550            let flow = item
551                .children()
552                .find(|n| n.kind() == SyntaxKind::YAML_FLOW_SEQUENCE)
553                .expect("flow sequence inside item");
554            let flow_items = flow
555                .children()
556                .filter(|n| n.kind() == SyntaxKind::YAML_FLOW_SEQUENCE_ITEM)
557                .count();
558            assert_eq!(flow_items, 2);
559        }
560    }
561
562    #[test]
563    fn lexer_recognizes_single_bang_tag_in_top_level_scalar() {
564        let tokens = lex_mapping_tokens("! a\n").expect("tokens");
565        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
566        assert_eq!(
567            kinds,
568            vec![
569                YamlToken::Tag,
570                YamlToken::Whitespace,
571                YamlToken::Scalar,
572                YamlToken::Newline,
573            ]
574        );
575        let texts: Vec<_> = tokens.iter().map(|t| t.text).collect();
576        assert_eq!(texts, vec!["!", " ", "a", "\n"]);
577    }
578
579    #[test]
580    fn parser_emits_scalar_document_for_tag_without_colon() {
581        let input = "! a\n";
582        let tree = parse_yaml_tree(input).expect("tree");
583        assert_eq!(tree.text().to_string(), input);
584
585        let has_block_map = tree
586            .descendants()
587            .any(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP);
588        assert!(
589            !has_block_map,
590            "scalar document should not be wrapped in YAML_BLOCK_MAP"
591        );
592
593        let has_tag = tree
594            .descendants_with_tokens()
595            .filter_map(|el| el.into_token())
596            .any(|tok| tok.kind() == SyntaxKind::YAML_TAG && tok.text() == "!");
597        assert!(has_tag, "tree should contain YAML_TAG '!'");
598    }
599
600    #[test]
601    fn lexer_extracts_explicit_tag_before_block_sequence_scalar() {
602        let tokens = lex_mapping_tokens("- !!int 1\n").expect("tokens");
603        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
604        assert_eq!(
605            kinds,
606            vec![
607                YamlToken::BlockSeqEntry,
608                YamlToken::Whitespace,
609                YamlToken::Tag,
610                YamlToken::Whitespace,
611                YamlToken::Scalar,
612                YamlToken::Newline,
613            ]
614        );
615        let texts: Vec<_> = tokens.iter().map(|t| t.text).collect();
616        assert_eq!(texts, vec!["-", " ", "!!int", " ", "1", "\n"]);
617    }
618
619    #[test]
620    fn parser_builds_nested_block_map_inside_block_sequence() {
621        let input = "-\n  name: Mark\n  hr: 65\n";
622        let tree = parse_yaml_tree(input).expect("tree");
623        assert_eq!(tree.text().to_string(), input);
624
625        let seq = tree
626            .descendants()
627            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE)
628            .expect("block sequence");
629        let items: Vec<_> = seq
630            .children()
631            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE_ITEM)
632            .collect();
633        assert_eq!(items.len(), 1);
634
635        let nested_map = items[0]
636            .children()
637            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
638            .expect("nested block map inside sequence item");
639        let entry_count = nested_map
640            .children()
641            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
642            .count();
643        assert_eq!(entry_count, 2);
644    }
645
646    #[test]
647    fn parser_builds_nested_block_map_from_indent_tokens() {
648        let input = "root: 1\n  child: 2\n";
649        let tree = parse_yaml_tree(input).expect("tree");
650
651        let outer_map = tree
652            .descendants()
653            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
654            .expect("outer map");
655        let outer_entry = outer_map
656            .children()
657            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
658            .expect("outer entry");
659        let outer_value = outer_entry
660            .children()
661            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_VALUE)
662            .expect("outer value");
663
664        let nested_map = outer_value
665            .children()
666            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
667            .expect("nested map");
668        let nested_entry_count = nested_map
669            .children()
670            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
671            .count();
672        assert_eq!(nested_entry_count, 1);
673    }
674
675    #[test]
676    fn shadow_parse_is_disabled_by_default() {
677        let report = parse_shadow("title: My Title", ShadowYamlOptions::default());
678        assert_eq!(report.outcome, ShadowYamlOutcome::SkippedDisabled);
679        assert_eq!(report.shadow_reason, "shadow-disabled");
680        assert_eq!(report.normalized_input, None);
681    }
682
683    #[test]
684    fn shadow_parse_skips_when_disabled_even_for_valid_input() {
685        let report = parse_shadow(
686            "title: My Title",
687            ShadowYamlOptions {
688                enabled: false,
689                input_kind: YamlInputKind::Plain,
690            },
691        );
692        assert_eq!(report.outcome, ShadowYamlOutcome::SkippedDisabled);
693        assert_eq!(report.shadow_reason, "shadow-disabled");
694    }
695
696    #[test]
697    fn shadow_parse_reports_prototype_parsed_when_enabled() {
698        let report = parse_shadow(
699            "title: My Title",
700            ShadowYamlOptions {
701                enabled: true,
702                input_kind: YamlInputKind::Plain,
703            },
704        );
705        assert_eq!(report.outcome, ShadowYamlOutcome::PrototypeParsed);
706        assert_eq!(report.shadow_reason, "prototype-basic-mapping-parsed");
707        assert_eq!(report.normalized_input.as_deref(), Some("title: My Title"));
708    }
709
710    #[test]
711    fn shadow_parse_reports_prototype_rejected_when_enabled() {
712        // Tab indentation is prohibited by YAML spec for block structures
713        let report = parse_shadow(
714            "\ttitle: value",
715            ShadowYamlOptions {
716                enabled: true,
717                input_kind: YamlInputKind::Plain,
718            },
719        );
720        assert_eq!(report.outcome, ShadowYamlOutcome::PrototypeRejected);
721        assert_eq!(report.shadow_reason, "prototype-basic-mapping-rejected");
722    }
723
724    #[test]
725    fn shadow_parse_accepts_hashpipe_mode_but_remains_prototype_scoped() {
726        let report = parse_shadow(
727            "#| title: My Title",
728            ShadowYamlOptions {
729                enabled: true,
730                input_kind: YamlInputKind::Hashpipe,
731            },
732        );
733        assert_eq!(report.outcome, ShadowYamlOutcome::PrototypeParsed);
734        assert_eq!(report.shadow_reason, "prototype-basic-mapping-parsed");
735        assert_eq!(report.normalized_input.as_deref(), Some("title: My Title"));
736    }
737}