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/core.rs"]
12mod core;
13#[path = "yaml/model.rs"]
14mod model;
15
16pub use core::{
17    lex_basic_mapping_tokens, parse_basic_entry, parse_basic_entry_tree, parse_basic_mapping_tree,
18    parse_shadow,
19};
20pub use model::{
21    BasicYamlEntry, ShadowYamlOptions, ShadowYamlOutcome, ShadowYamlReport, YamlInputKind,
22    YamlShadowToken, YamlShadowTokenKind,
23};
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28    use crate::syntax::SyntaxKind;
29
30    #[test]
31    fn parses_basic_title_entry() {
32        let parsed = parse_basic_entry("title: My Title");
33        assert_eq!(
34            parsed,
35            Some(BasicYamlEntry {
36                key: "title",
37                value: "My Title"
38            })
39        );
40    }
41
42    #[test]
43    fn parses_single_line_with_multiple_colons() {
44        let parsed = parse_basic_entry("a: b: c: d");
45        assert_eq!(
46            parsed,
47            Some(BasicYamlEntry {
48                key: "a",
49                value: "b: c: d"
50            })
51        );
52    }
53
54    #[test]
55    fn rejects_missing_value() {
56        assert_eq!(parse_basic_entry("title:"), None);
57    }
58
59    #[test]
60    fn rejects_multiline_input() {
61        assert_eq!(parse_basic_entry("title: My Title\nauthor: Me"), None);
62    }
63
64    #[test]
65    fn accepts_single_line_with_crlf_terminator() {
66        let parsed = parse_basic_entry("title: My Title\r");
67        assert_eq!(
68            parsed,
69            Some(BasicYamlEntry {
70                key: "title",
71                value: "My Title"
72            })
73        );
74    }
75
76    #[test]
77    fn builds_basic_rowan_tree() {
78        let tree = parse_basic_entry_tree("title: My Title").expect("tree");
79        assert_eq!(tree.kind(), SyntaxKind::DOCUMENT);
80        assert_eq!(tree.text().to_string(), "title: My Title");
81
82        let content = tree
83            .children()
84            .find(|n| n.kind() == SyntaxKind::YAML_METADATA_CONTENT)
85            .expect("yaml metadata content");
86        assert_eq!(content.text().to_string(), "title: My Title");
87
88        let mapping = content
89            .children()
90            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
91            .expect("yaml block map");
92        let entry = mapping
93            .children()
94            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
95            .expect("yaml block map entry");
96        let key = entry
97            .children()
98            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_KEY)
99            .expect("yaml block map key");
100        let value = entry
101            .children()
102            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_VALUE)
103            .expect("yaml block map value");
104
105        let key_token_kinds: Vec<_> = key
106            .children_with_tokens()
107            .filter_map(|el| el.into_token())
108            .map(|tok| tok.kind())
109            .collect();
110        assert_eq!(
111            key_token_kinds,
112            vec![SyntaxKind::YAML_KEY, SyntaxKind::YAML_COLON,]
113        );
114
115        let value_token_kinds: Vec<_> = value
116            .children_with_tokens()
117            .filter_map(|el| el.into_token())
118            .map(|tok| tok.kind())
119            .collect();
120        assert_eq!(
121            value_token_kinds,
122            vec![SyntaxKind::WHITESPACE, SyntaxKind::YAML_SCALAR,]
123        );
124    }
125
126    #[test]
127    fn builds_basic_rowan_tree_for_multiline_mapping() {
128        let tree = parse_basic_mapping_tree("title: My Title\nauthor: Me\n").expect("tree");
129        assert_eq!(tree.kind(), SyntaxKind::DOCUMENT);
130        assert_eq!(tree.text().to_string(), "title: My Title\nauthor: Me\n");
131
132        let content = tree
133            .children()
134            .find(|n| n.kind() == SyntaxKind::YAML_METADATA_CONTENT)
135            .expect("yaml metadata content");
136        let mapping = content
137            .children()
138            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
139            .expect("yaml block map");
140        let entries: Vec<_> = mapping
141            .children()
142            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
143            .collect();
144        assert_eq!(entries.len(), 2);
145
146        let token_kinds: Vec<_> = mapping
147            .descendants_with_tokens()
148            .filter_map(|el| el.into_token())
149            .map(|tok| tok.kind())
150            .collect();
151        assert_eq!(
152            token_kinds,
153            vec![
154                SyntaxKind::YAML_KEY,
155                SyntaxKind::YAML_COLON,
156                SyntaxKind::WHITESPACE,
157                SyntaxKind::YAML_SCALAR,
158                SyntaxKind::NEWLINE,
159                SyntaxKind::YAML_KEY,
160                SyntaxKind::YAML_COLON,
161                SyntaxKind::WHITESPACE,
162                SyntaxKind::YAML_SCALAR,
163                SyntaxKind::NEWLINE,
164            ]
165        );
166    }
167
168    #[test]
169    fn mapping_nodes_preserve_entry_text_boundaries() {
170        let tree = parse_basic_mapping_tree("title: A\nauthor: B\n").expect("tree");
171        let content = tree
172            .children()
173            .find(|n| n.kind() == SyntaxKind::YAML_METADATA_CONTENT)
174            .expect("yaml metadata content");
175        let mapping = content
176            .children()
177            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
178            .expect("yaml block map");
179
180        let entry_texts: Vec<_> = mapping
181            .children()
182            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
183            .map(|n| n.text().to_string())
184            .collect();
185        assert_eq!(
186            entry_texts,
187            vec!["title: A\n".to_string(), "author: B\n".to_string(),]
188        );
189    }
190
191    #[test]
192    fn splits_mapping_on_colon_outside_quoted_key() {
193        let input = "\"foo:bar\": 23\n'x:y': 24\n";
194        let tree = parse_basic_mapping_tree(input).expect("tree");
195        assert_eq!(tree.text().to_string(), input);
196
197        let keys: Vec<String> = tree
198            .descendants_with_tokens()
199            .filter_map(|el| el.into_token())
200            .filter(|tok| tok.kind() == SyntaxKind::YAML_KEY)
201            .map(|tok| tok.text().to_string())
202            .collect();
203        assert_eq!(keys, vec!["\"foo:bar\"".to_string(), "'x:y'".to_string()]);
204    }
205
206    #[test]
207    fn preserves_explicit_tag_tokens_in_key_and_value() {
208        let input = "!!str a: !!int 42\n";
209        let tree = parse_basic_mapping_tree(input).expect("tree");
210        assert_eq!(tree.text().to_string(), input);
211
212        let tag_tokens: Vec<_> = tree
213            .descendants_with_tokens()
214            .filter_map(|el| el.into_token())
215            .filter(|tok| tok.kind() == SyntaxKind::YAML_TAG)
216            .map(|tok| tok.text().to_string())
217            .collect();
218        assert_eq!(tag_tokens, vec!["!!str".to_string(), "!!int".to_string()]);
219    }
220
221    #[test]
222    fn lexer_emits_tokens_for_quoted_keys_and_inline_comments() {
223        let input = "\"foo:bar\": 23 # note\n'x:y': 'z' # ok\n";
224        let tokens = lex_basic_mapping_tokens(input).expect("tokens");
225        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
226        assert_eq!(
227            kinds,
228            vec![
229                YamlShadowTokenKind::Key,
230                YamlShadowTokenKind::Colon,
231                YamlShadowTokenKind::Whitespace,
232                YamlShadowTokenKind::Scalar,
233                YamlShadowTokenKind::Whitespace,
234                YamlShadowTokenKind::Comment,
235                YamlShadowTokenKind::Newline,
236                YamlShadowTokenKind::Key,
237                YamlShadowTokenKind::Colon,
238                YamlShadowTokenKind::Whitespace,
239                YamlShadowTokenKind::Scalar,
240                YamlShadowTokenKind::Whitespace,
241                YamlShadowTokenKind::Comment,
242                YamlShadowTokenKind::Newline,
243            ]
244        );
245        let comments: Vec<_> = tokens
246            .iter()
247            .filter(|t| t.kind == YamlShadowTokenKind::Comment)
248            .map(|t| t.text)
249            .collect();
250        assert_eq!(comments, vec!["# note", "# ok"]);
251    }
252
253    #[test]
254    fn lexer_emits_indent_and_dedent_for_indented_entries() {
255        let input = "root: 1\n  child: 2\n";
256        let tokens = lex_basic_mapping_tokens(input).expect("tokens");
257        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
258        assert!(kinds.contains(&YamlShadowTokenKind::Indent));
259        assert!(kinds.contains(&YamlShadowTokenKind::Dedent));
260    }
261
262    #[test]
263    fn parser_builds_nested_block_map_from_indent_tokens() {
264        let input = "root: 1\n  child: 2\n";
265        let tree = parse_basic_mapping_tree(input).expect("tree");
266
267        let outer_map = tree
268            .descendants()
269            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
270            .expect("outer map");
271        let outer_entry = outer_map
272            .children()
273            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
274            .expect("outer entry");
275        let outer_value = outer_entry
276            .children()
277            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_VALUE)
278            .expect("outer value");
279
280        let nested_map = outer_value
281            .children()
282            .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP)
283            .expect("nested map");
284        let nested_entry_count = nested_map
285            .children()
286            .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
287            .count();
288        assert_eq!(nested_entry_count, 1);
289    }
290
291    #[test]
292    fn rejects_tree_for_invalid_input() {
293        assert!(parse_basic_entry_tree("title:").is_none());
294    }
295
296    #[test]
297    fn shadow_parse_is_disabled_by_default() {
298        let report = parse_shadow("title: My Title", ShadowYamlOptions::default());
299        assert_eq!(report.outcome, ShadowYamlOutcome::SkippedDisabled);
300        assert_eq!(report.shadow_reason, "shadow-disabled");
301        assert_eq!(report.normalized_input, None);
302    }
303
304    #[test]
305    fn shadow_parse_skips_when_disabled_even_for_valid_input() {
306        let report = parse_shadow(
307            "title: My Title",
308            ShadowYamlOptions {
309                enabled: false,
310                input_kind: YamlInputKind::Plain,
311            },
312        );
313        assert_eq!(report.outcome, ShadowYamlOutcome::SkippedDisabled);
314        assert_eq!(report.shadow_reason, "shadow-disabled");
315    }
316
317    #[test]
318    fn shadow_parse_reports_prototype_parsed_when_enabled() {
319        let report = parse_shadow(
320            "title: My Title",
321            ShadowYamlOptions {
322                enabled: true,
323                input_kind: YamlInputKind::Plain,
324            },
325        );
326        assert_eq!(report.outcome, ShadowYamlOutcome::PrototypeParsed);
327        assert_eq!(report.shadow_reason, "prototype-basic-mapping-parsed");
328        assert_eq!(report.normalized_input.as_deref(), Some("title: My Title"));
329    }
330
331    #[test]
332    fn shadow_parse_reports_prototype_rejected_when_enabled() {
333        let report = parse_shadow(
334            "title:",
335            ShadowYamlOptions {
336                enabled: true,
337                input_kind: YamlInputKind::Plain,
338            },
339        );
340        assert_eq!(report.outcome, ShadowYamlOutcome::PrototypeRejected);
341        assert_eq!(report.shadow_reason, "prototype-basic-mapping-rejected");
342    }
343
344    #[test]
345    fn shadow_parse_accepts_hashpipe_mode_but_remains_prototype_scoped() {
346        let report = parse_shadow(
347            "#| title: My Title",
348            ShadowYamlOptions {
349                enabled: true,
350                input_kind: YamlInputKind::Hashpipe,
351            },
352        );
353        assert_eq!(report.outcome, ShadowYamlOutcome::PrototypeParsed);
354        assert_eq!(report.shadow_reason, "prototype-basic-mapping-parsed");
355        assert_eq!(report.normalized_input.as_deref(), Some("title: My Title"));
356    }
357}