Skip to main content

styx_format/
cst_format.rs

1//! CST-based formatter for Styx documents.
2//!
3//! This formatter works directly with the lossless CST (Concrete Syntax Tree),
4//! preserving all comments and producing properly indented output.
5
6use styx_cst::{
7    AstNode, Document, Entry, NodeOrToken, Object, Separator, Sequence, SyntaxKind, SyntaxNode,
8};
9
10use crate::FormatOptions;
11
12/// Format a Styx document from its CST.
13///
14/// This preserves all comments and produces properly indented output.
15pub fn format_cst(node: &SyntaxNode, options: FormatOptions) -> String {
16    let mut formatter = CstFormatter::new(options);
17    formatter.format_node(node);
18    formatter.finish()
19}
20
21/// Format a Styx document from source text.
22///
23/// Parses the source, formats the CST, and returns the formatted output.
24/// Returns the original source if parsing fails.
25pub fn format_source(source: &str, options: FormatOptions) -> String {
26    let parsed = styx_cst::parse(source);
27    if !parsed.is_ok() {
28        // Don't format documents with parse errors
29        return source.to_string();
30    }
31    format_cst(&parsed.syntax(), options)
32}
33
34struct CstFormatter {
35    out: String,
36    options: FormatOptions,
37    indent_level: usize,
38    /// Track if we're at the start of a line (for indentation)
39    at_line_start: bool,
40    /// Track if we just wrote a newline
41    after_newline: bool,
42}
43
44impl CstFormatter {
45    fn new(options: FormatOptions) -> Self {
46        Self {
47            out: String::new(),
48            options,
49            indent_level: 0,
50            at_line_start: true,
51            after_newline: false,
52        }
53    }
54
55    fn finish(mut self) -> String {
56        // Ensure trailing newline
57        if !self.out.ends_with('\n') && !self.out.is_empty() {
58            self.out.push('\n');
59        }
60        self.out
61    }
62
63    fn write_indent(&mut self) {
64        if self.at_line_start && self.indent_level > 0 {
65            for _ in 0..self.indent_level {
66                self.out.push_str(self.options.indent);
67            }
68        }
69        self.at_line_start = false;
70    }
71
72    fn write(&mut self, s: &str) {
73        if s.is_empty() {
74            return;
75        }
76        self.write_indent();
77        self.out.push_str(s);
78        self.after_newline = false;
79    }
80
81    fn write_newline(&mut self) {
82        self.out.push('\n');
83        self.at_line_start = true;
84        self.after_newline = true;
85    }
86
87    fn format_node(&mut self, node: &SyntaxNode) {
88        match node.kind() {
89            // Nodes
90            SyntaxKind::DOCUMENT => self.format_document(node),
91            SyntaxKind::ENTRY => self.format_entry(node),
92            SyntaxKind::OBJECT => self.format_object(node),
93            SyntaxKind::SEQUENCE => self.format_sequence(node),
94            SyntaxKind::KEY => self.format_key(node),
95            SyntaxKind::VALUE => self.format_value(node),
96            SyntaxKind::SCALAR => self.format_scalar(node),
97            SyntaxKind::TAG => self.format_tag(node),
98            SyntaxKind::TAG_PAYLOAD => self.format_tag_payload(node),
99            SyntaxKind::UNIT => self.write("@"),
100            SyntaxKind::HEREDOC => self.format_heredoc(node),
101            SyntaxKind::ATTRIBUTES => self.format_attributes(node),
102            SyntaxKind::ATTRIBUTE => self.format_attribute(node),
103
104            // Tokens - should not appear as nodes, but handle gracefully
105            SyntaxKind::L_BRACE
106            | SyntaxKind::R_BRACE
107            | SyntaxKind::L_PAREN
108            | SyntaxKind::R_PAREN
109            | SyntaxKind::COMMA
110            | SyntaxKind::GT
111            | SyntaxKind::AT
112            | SyntaxKind::TAG_TOKEN
113            | SyntaxKind::SLASH
114            | SyntaxKind::BARE_SCALAR
115            | SyntaxKind::QUOTED_SCALAR
116            | SyntaxKind::RAW_SCALAR
117            | SyntaxKind::HEREDOC_START
118            | SyntaxKind::HEREDOC_CONTENT
119            | SyntaxKind::HEREDOC_END
120            | SyntaxKind::LINE_COMMENT
121            | SyntaxKind::DOC_COMMENT
122            | SyntaxKind::WHITESPACE
123            | SyntaxKind::NEWLINE
124            | SyntaxKind::EOF
125            | SyntaxKind::ERROR
126            | SyntaxKind::__LAST_TOKEN
127            | SyntaxKind::TAG_NAME => {
128                // Tokens shouldn't be passed to format_node (they're not nodes)
129                // but if they are, ignore them - they're handled by their parent
130            }
131        }
132    }
133
134    fn format_document(&mut self, node: &SyntaxNode) {
135        let doc = Document::cast(node.clone()).unwrap();
136        let entries: Vec<_> = doc.entries().collect();
137
138        // Track consecutive newlines to preserve blank lines from input
139        let mut consecutive_newlines = 0;
140        let mut entry_index = 0;
141        let mut wrote_content = false;
142        // Track if we just wrote a doc comment (entry should follow without blank line)
143        let mut just_wrote_doc_comment = false;
144
145        for el in node.children_with_tokens() {
146            match el.kind() {
147                SyntaxKind::NEWLINE => {
148                    consecutive_newlines += 1;
149                }
150                SyntaxKind::WHITESPACE => {
151                    // Ignore whitespace
152                }
153                SyntaxKind::LINE_COMMENT => {
154                    if let Some(token) = el.into_token() {
155                        if wrote_content {
156                            self.write_newline();
157                            // 2+ consecutive newlines means there was a blank line
158                            if consecutive_newlines >= 2 {
159                                self.write_newline();
160                            }
161                        }
162                        self.write(token.text());
163                        wrote_content = true;
164                        consecutive_newlines = 0;
165                        just_wrote_doc_comment = false;
166                    }
167                }
168                SyntaxKind::DOC_COMMENT => {
169                    if let Some(token) = el.into_token() {
170                        if wrote_content {
171                            self.write_newline();
172
173                            // Add extra blank line before doc comment:
174                            // - if source had 2+ consecutive newlines (preserve existing)
175                            // - if previous entry was schema declaration
176                            // - if previous entry had doc comments
177                            // - if previous entry is a block (issue #28)
178                            // BUT NOT if we just wrote a doc comment (consecutive doc comments
179                            // should stay together without blank lines)
180                            if !just_wrote_doc_comment {
181                                let had_blank_line = consecutive_newlines >= 2;
182                                let prev_was_schema =
183                                    entry_index == 1 && is_schema_declaration(&entries[0]);
184                                let prev_had_doc = entry_index > 0
185                                    && entries[entry_index - 1].doc_comments().next().is_some();
186                                let prev_is_block =
187                                    entry_index > 0 && is_block_entry(&entries[entry_index - 1]);
188
189                                if had_blank_line
190                                    || prev_was_schema
191                                    || prev_had_doc
192                                    || prev_is_block
193                                {
194                                    self.write_newline();
195                                }
196                            }
197                        }
198                        self.write(token.text());
199                        wrote_content = true;
200                        consecutive_newlines = 0;
201                        just_wrote_doc_comment = true;
202                    }
203                }
204                SyntaxKind::ENTRY => {
205                    if let Some(entry_node) = el.into_node() {
206                        let entry = &entries[entry_index];
207
208                        if wrote_content {
209                            self.write_newline();
210
211                            // Add extra blank line before entry (only if not preceded by doc comment):
212                            // - if source had 2+ consecutive newlines (preserve existing blank lines)
213                            // - if previous entry was schema declaration (@ entry at root)
214                            // - if previous entry had doc comments (and this entry has none)
215                            // - if previous or current entry is a block (issue #28)
216                            if !just_wrote_doc_comment {
217                                let had_blank_line = consecutive_newlines >= 2;
218                                let prev_was_schema =
219                                    entry_index == 1 && is_schema_declaration(&entries[0]);
220                                let prev_had_doc = entry_index > 0
221                                    && entries[entry_index - 1].doc_comments().next().is_some();
222                                let prev_is_block =
223                                    entry_index > 0 && is_block_entry(&entries[entry_index - 1]);
224                                let current_is_block = is_block_entry(entry);
225
226                                if had_blank_line
227                                    || prev_was_schema
228                                    || prev_had_doc
229                                    || prev_is_block
230                                    || current_is_block
231                                {
232                                    self.write_newline();
233                                }
234                            }
235                        }
236
237                        self.format_node(&entry_node);
238                        wrote_content = true;
239                        consecutive_newlines = 0;
240                        entry_index += 1;
241                        just_wrote_doc_comment = false;
242                    }
243                }
244                _ => {
245                    // Skip other tokens
246                }
247            }
248        }
249    }
250
251    fn format_entry(&mut self, node: &SyntaxNode) {
252        let entry = Entry::cast(node.clone()).unwrap();
253
254        if let Some(key) = entry.key() {
255            self.format_node(key.syntax());
256        }
257
258        // Space between key and value
259        if entry.value().is_some() {
260            self.write(" ");
261        }
262
263        if let Some(value) = entry.value() {
264            self.format_node(value.syntax());
265        }
266    }
267
268    fn format_object(&mut self, node: &SyntaxNode) {
269        let obj = Object::cast(node.clone()).unwrap();
270        let entries: Vec<_> = obj.entries().collect();
271        let separator = obj.separator();
272
273        self.write("{");
274
275        // Check if the object contains any comments (even if no entries)
276        let has_comments = node.children_with_tokens().any(|el| {
277            matches!(
278                el.kind(),
279                SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT
280            )
281        });
282
283        // Empty object with no comments
284        if entries.is_empty() && !has_comments {
285            self.write("}");
286            return;
287        }
288
289        // Check if any entry contains a block object - if so, parent should expand too
290        let has_block_child = entries.iter().any(|e| contains_block_object(e.syntax()));
291
292        // Determine if we need multiline format
293        let is_multiline = matches!(separator, Separator::Newline | Separator::Mixed)
294            || has_comments
295            || has_block_child
296            || entries.is_empty(); // Empty with comments needs multiline
297
298        if is_multiline {
299            // Multiline format - preserve comments as children of the object
300            self.write_newline();
301            self.indent_level += 1;
302
303            // Iterate through all children to preserve comments in order
304            // Track consecutive newlines to preserve blank lines
305            let mut wrote_content = false;
306            let mut consecutive_newlines = 0;
307            for el in node.children_with_tokens() {
308                match el.kind() {
309                    SyntaxKind::NEWLINE => {
310                        consecutive_newlines += 1;
311                    }
312                    SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT => {
313                        if let Some(token) = el.into_token() {
314                            if wrote_content {
315                                self.write_newline();
316                                // 2+ consecutive newlines means there was a blank line
317                                if consecutive_newlines >= 2 {
318                                    self.write_newline();
319                                }
320                            }
321                            self.write(token.text());
322                            wrote_content = true;
323                            consecutive_newlines = 0;
324                        }
325                    }
326                    SyntaxKind::ENTRY => {
327                        if let Some(entry_node) = el.into_node() {
328                            if wrote_content {
329                                self.write_newline();
330                                // 2+ consecutive newlines means there was a blank line
331                                if consecutive_newlines >= 2 {
332                                    self.write_newline();
333                                }
334                            }
335                            self.format_node(&entry_node);
336                            wrote_content = true;
337                            consecutive_newlines = 0;
338                        }
339                    }
340                    // Skip whitespace, braces - we handle formatting ourselves
341                    // Whitespace doesn't reset newline count (it comes between newlines)
342                    SyntaxKind::WHITESPACE | SyntaxKind::L_BRACE | SyntaxKind::R_BRACE => {}
343                    _ => {
344                        consecutive_newlines = 0;
345                    }
346                }
347            }
348
349            self.write_newline();
350            self.indent_level -= 1;
351            self.write("}");
352        } else {
353            // Inline format (comma-separated, no comments)
354            for (i, entry) in entries.iter().enumerate() {
355                self.format_node(entry.syntax());
356
357                if i < entries.len() - 1 {
358                    self.write(", ");
359                }
360            }
361            self.write("}");
362        }
363    }
364
365    fn format_sequence(&mut self, node: &SyntaxNode) {
366        let seq = Sequence::cast(node.clone()).unwrap();
367        let entries: Vec<_> = seq.entries().collect();
368
369        self.write("(");
370
371        // Check if the sequence contains any comments (even if no entries)
372        let has_comments = node.children_with_tokens().any(|el| {
373            matches!(
374                el.kind(),
375                SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT
376            )
377        });
378
379        // Empty sequence with no comments
380        if entries.is_empty() && !has_comments {
381            self.write(")");
382            return;
383        }
384
385        // Determine if we need multiline format
386        // But collapse trivial sequences: single simple element should be inline
387        let should_collapse =
388            !has_comments && entries.len() == 1 && !contains_block_object(entries[0].syntax());
389
390        // Special case: single entry that is a tag with block payload - format inline with paren
391        // e.g., @optional(@object{...}) should format as (@object{\n...\n}) not (\n@object{...}\n)
392        let single_tag_with_block =
393            !has_comments && entries.len() == 1 && is_tag_with_block_payload(entries[0].syntax());
394
395        let is_multiline = !should_collapse
396            && !single_tag_with_block
397            && (seq.is_multiline() || has_comments || entries.is_empty());
398
399        if single_tag_with_block {
400            // Format the single entry inline with the paren - no newline after (
401            if let Some(key) = entries[0]
402                .syntax()
403                .children()
404                .find(|n| n.kind() == SyntaxKind::KEY)
405            {
406                for child in key.children() {
407                    self.format_node(&child);
408                }
409            }
410            self.write(")");
411        } else if is_multiline {
412            // Multiline format - preserve comments as children of the sequence
413            self.write_newline();
414            self.indent_level += 1;
415
416            // Iterate through all children to preserve comments in order
417            let mut wrote_content = false;
418            let mut consecutive_newlines = 0;
419            for el in node.children_with_tokens() {
420                match el.kind() {
421                    SyntaxKind::NEWLINE => {
422                        consecutive_newlines += 1;
423                    }
424                    SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT => {
425                        if let Some(token) = el.into_token() {
426                            if wrote_content {
427                                self.write_newline();
428                                // 2+ consecutive newlines means there was a blank line
429                                if consecutive_newlines >= 2 {
430                                    self.write_newline();
431                                }
432                            }
433                            self.write(token.text());
434                            wrote_content = true;
435                            consecutive_newlines = 0;
436                        }
437                    }
438                    SyntaxKind::ENTRY => {
439                        if let Some(entry_node) = el.into_node() {
440                            if wrote_content {
441                                self.write_newline();
442                                // 2+ consecutive newlines means there was a blank line
443                                if consecutive_newlines >= 2 {
444                                    self.write_newline();
445                                }
446                            }
447                            // Format the entry's value (sequence entries have implicit unit keys)
448                            if let Some(key) =
449                                entry_node.children().find(|n| n.kind() == SyntaxKind::KEY)
450                            {
451                                for child in key.children() {
452                                    self.format_node(&child);
453                                }
454                            }
455                            wrote_content = true;
456                            consecutive_newlines = 0;
457                        }
458                    }
459                    // Skip whitespace, parens - we handle formatting ourselves
460                    SyntaxKind::WHITESPACE | SyntaxKind::L_PAREN | SyntaxKind::R_PAREN => {}
461                    _ => {
462                        consecutive_newlines = 0;
463                    }
464                }
465            }
466
467            self.write_newline();
468            self.indent_level -= 1;
469            self.write(")");
470        } else {
471            // Inline format - single line with spaces (no comments possible here)
472            for (i, entry) in entries.iter().enumerate() {
473                // Get the actual value from the entry's key
474                if let Some(key) = entry
475                    .syntax()
476                    .children()
477                    .find(|n| n.kind() == SyntaxKind::KEY)
478                {
479                    for child in key.children() {
480                        self.format_node(&child);
481                    }
482                }
483
484                if i < entries.len() - 1 {
485                    self.write(" ");
486                }
487            }
488            self.write(")");
489        }
490    }
491
492    fn format_key(&mut self, node: &SyntaxNode) {
493        // Format the key content (scalar, tag, unit, etc.)
494        for child in node.children() {
495            self.format_node(&child);
496        }
497
498        // Also check for direct tokens (like BARE_SCALAR in simple keys)
499        for token in node.children_with_tokens().filter_map(|el| el.into_token()) {
500            match token.kind() {
501                SyntaxKind::BARE_SCALAR | SyntaxKind::QUOTED_SCALAR | SyntaxKind::RAW_SCALAR => {
502                    self.write(token.text());
503                }
504                _ => {}
505            }
506        }
507    }
508
509    fn format_value(&mut self, node: &SyntaxNode) {
510        for child in node.children() {
511            self.format_node(&child);
512        }
513    }
514
515    fn format_scalar(&mut self, node: &SyntaxNode) {
516        // Get the scalar token and write it as-is
517        for token in node.children_with_tokens().filter_map(|el| el.into_token()) {
518            match token.kind() {
519                SyntaxKind::BARE_SCALAR | SyntaxKind::QUOTED_SCALAR | SyntaxKind::RAW_SCALAR => {
520                    self.write(token.text());
521                }
522                _ => {}
523            }
524        }
525    }
526
527    fn format_tag(&mut self, node: &SyntaxNode) {
528        // The TAG node contains:
529        // - TAG_TOKEN: the full `@name` text (including @)
530        // - optionally TAG_PAYLOAD: the payload node
531        for el in node.children_with_tokens() {
532            match el {
533                NodeOrToken::Token(token) if token.kind() == SyntaxKind::TAG_TOKEN => {
534                    // Write the full tag token including @
535                    self.write(token.text());
536                }
537                NodeOrToken::Node(child) if child.kind() == SyntaxKind::TAG_PAYLOAD => {
538                    self.format_tag_payload(&child);
539                }
540                _ => {}
541            }
542        }
543    }
544
545    fn format_tag_payload(&mut self, node: &SyntaxNode) {
546        for el in node.children_with_tokens() {
547            match el {
548                NodeOrToken::Token(token) if token.kind() == SyntaxKind::SLASH => {
549                    self.write("/");
550                }
551                NodeOrToken::Node(child) => match child.kind() {
552                    SyntaxKind::SEQUENCE => {
553                        // Sequence payload: @tag(...)
554                        self.format_sequence(&child);
555                    }
556                    SyntaxKind::OBJECT => {
557                        // Object payload: @tag{...}
558                        self.format_object(&child);
559                    }
560                    _ => self.format_node(&child),
561                },
562                _ => {}
563            }
564        }
565    }
566
567    fn format_heredoc(&mut self, node: &SyntaxNode) {
568        // Heredocs are preserved as-is
569        self.write(&node.to_string());
570    }
571
572    fn format_attributes(&mut self, node: &SyntaxNode) {
573        let attrs: Vec<_> = node
574            .children()
575            .filter(|n| n.kind() == SyntaxKind::ATTRIBUTE)
576            .collect();
577
578        for (i, attr) in attrs.iter().enumerate() {
579            self.format_attribute(attr);
580            if i < attrs.len() - 1 {
581                self.write(" ");
582            }
583        }
584    }
585
586    fn format_attribute(&mut self, node: &SyntaxNode) {
587        // Attribute structure: BARE_SCALAR ">" SCALAR
588        for el in node.children_with_tokens() {
589            match el {
590                NodeOrToken::Token(token) => match token.kind() {
591                    SyntaxKind::BARE_SCALAR => self.write(token.text()),
592                    SyntaxKind::GT => self.write(">"),
593                    _ => {}
594                },
595                NodeOrToken::Node(child) => {
596                    self.format_node(&child);
597                }
598            }
599        }
600    }
601}
602
603/// Check if an entry is a "block" entry (contains a multiline object at top level).
604/// Block entries need blank lines around them per issue #28.
605fn is_block_entry(entry: &Entry) -> bool {
606    if let Some(value) = entry.value() {
607        // Check if the value directly contains a block-style object
608        contains_block_object(value.syntax())
609    } else {
610        false
611    }
612}
613
614/// Check if a sequence entry is a tag with a block object payload.
615/// Used to format `(@object{...})` as `(@object{\n...\n})` not `(\n@object{...}\n)`.
616fn is_tag_with_block_payload(entry_node: &SyntaxNode) -> bool {
617    // Find the KEY child of the entry
618    let key = match entry_node.children().find(|n| n.kind() == SyntaxKind::KEY) {
619        Some(k) => k,
620        None => return false,
621    };
622
623    // Look for a TAG child in the key
624    for child in key.children() {
625        if child.kind() == SyntaxKind::TAG {
626            // Check if this tag has a TAG_PAYLOAD with a block object
627            for tag_child in child.children() {
628                if tag_child.kind() == SyntaxKind::TAG_PAYLOAD {
629                    // Check if the payload contains a block object
630                    return contains_block_object(&tag_child);
631                }
632            }
633        }
634    }
635
636    false
637}
638
639/// Recursively check if a node contains a block-style object or doc comments.
640/// Objects with doc comments also need to be block-formatted.
641fn contains_block_object(node: &SyntaxNode) -> bool {
642    // Check this node if it's an object
643    if node.kind() == SyntaxKind::OBJECT
644        && let Some(obj) = Object::cast(node.clone())
645    {
646        let sep = obj.separator();
647        if matches!(sep, Separator::Newline | Separator::Mixed) {
648            return true;
649        }
650        // Also check if the object contains doc comments
651        if node
652            .children_with_tokens()
653            .any(|el| el.kind() == SyntaxKind::DOC_COMMENT)
654        {
655            return true;
656        }
657    }
658
659    // Recursively check all descendants
660    for child in node.children() {
661        if contains_block_object(&child) {
662            return true;
663        }
664    }
665
666    false
667}
668
669/// Check if an entry is a schema declaration (@schema tag as key).
670fn is_schema_declaration(entry: &Entry) -> bool {
671    if let Some(key) = entry.key() {
672        // Check if the key contains a @schema tag
673        key.syntax().children().any(|n| {
674            if n.kind() == SyntaxKind::TAG {
675                // Look for TAG_TOKEN with text "@schema"
676                n.children_with_tokens().any(|el| {
677                    if let NodeOrToken::Token(token) = el {
678                        token.kind() == SyntaxKind::TAG_TOKEN && token.text() == "@schema"
679                    } else {
680                        false
681                    }
682                })
683            } else {
684                false
685            }
686        })
687    } else {
688        false
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    fn format(source: &str) -> String {
697        format_source(source, FormatOptions::default())
698    }
699
700    #[test]
701    fn test_parse_errors_detected() {
702        // This input has a parse error - space-separated entries in inline object
703        let input = "config {a 1 b 2}";
704        let parsed = styx_cst::parse(input);
705        assert!(
706            !parsed.is_ok(),
707            "Expected parse errors for '{}', but got none. Errors: {:?}",
708            input,
709            parsed.errors()
710        );
711        // Formatter should return original source for documents with errors
712        let output = format(input);
713        assert_eq!(
714            output, input,
715            "Formatter should return original source for documents with parse errors"
716        );
717    }
718
719    #[test]
720    fn test_simple_document() {
721        let input = "name Alice\nage 30";
722        let output = format(input);
723        insta::assert_snapshot!(output);
724    }
725
726    #[test]
727    fn test_preserves_comments() {
728        let input = r#"// This is a comment
729name Alice
730/// Doc comment
731age 30"#;
732        let output = format(input);
733        insta::assert_snapshot!(output);
734    }
735
736    #[test]
737    fn test_inline_object() {
738        let input = "point {x 1, y 2}";
739        let output = format(input);
740        insta::assert_snapshot!(output);
741    }
742
743    #[test]
744    fn test_multiline_object() {
745        let input = "server {\n  host localhost\n  port 8080\n}";
746        let output = format(input);
747        insta::assert_snapshot!(output);
748    }
749
750    #[test]
751    fn test_nested_objects() {
752        let input = "config {\n  server {\n    host localhost\n  }\n}";
753        let output = format(input);
754        insta::assert_snapshot!(output);
755    }
756
757    #[test]
758    fn test_sequence() {
759        let input = "items (a b c)";
760        let output = format(input);
761        insta::assert_snapshot!(output);
762    }
763
764    #[test]
765    fn test_tagged_value() {
766        let input = "type @string";
767        let output = format(input);
768        insta::assert_snapshot!(output);
769    }
770
771    #[test]
772    fn test_schema_declaration() {
773        let input = "@schema schema.styx\n\nname test";
774        let output = format(input);
775        insta::assert_snapshot!(output);
776    }
777
778    #[test]
779    fn test_tag_with_nested_tag_payload() {
780        // Note: `@string @Schema` parses as @string with @Schema as its payload
781        // This is intentional grammar behavior - tags consume the next value as payload
782        let input = "@seq(@string @Schema)";
783        let output = format(input);
784        // The formatter must preserve the space before the nested payload
785        assert_eq!(output.trim(), "@seq(@string @Schema)");
786    }
787
788    #[test]
789    fn test_chained_tag_roundtrip() {
790        let input =
791            "events (@must_emit/@discover_start{executor default} @must_not_emit/@exec_start)";
792        let output = format(input);
793        assert_eq!(
794            output.trim(),
795            "events (@must_emit/@discover_start{executor default} @must_not_emit/@exec_start)"
796        );
797    }
798
799    #[test]
800    fn test_three_segment_chained_tag_roundtrip() {
801        let input = "value @a/@b/@c";
802        let output = format(input);
803        assert_eq!(output.trim(), input);
804    }
805
806    #[test]
807    fn test_chained_tag_with_raw_leaf_roundtrip() {
808        let input = r##"value @a/@br#"foo"#"##;
809        let output = format(input);
810        assert_eq!(output.trim(), input);
811    }
812
813    #[test]
814    fn test_chained_tag_with_heredoc_leaf_roundtrip() {
815        let input = "value @a/@b<<EOF\nhello\nEOF";
816        let output = format(input);
817        assert_eq!(output.trim_end(), input);
818    }
819
820    #[test]
821    fn test_sequence_with_multiple_scalars() {
822        let input = "(a b c)";
823        let output = format(input);
824        assert_eq!(output.trim(), "(a b c)");
825    }
826
827    #[test]
828    fn test_complex_schema() {
829        let input = r#"meta {
830  id https://example.com/schema
831  version 1.0
832}
833schema {
834  @ @object{
835    name @string
836    port @int
837  }
838}"#;
839        let output = format(input);
840        insta::assert_snapshot!(output);
841    }
842
843    #[test]
844    fn test_path_syntax_in_object() {
845        let input = r#"resources {
846    limits cpu>500m memory>256Mi
847    requests cpu>100m memory>128Mi
848}"#;
849        let output = format(input);
850        insta::assert_snapshot!(output);
851    }
852
853    #[test]
854    fn test_syntax_error_space_after_gt() {
855        // Space after > is a syntax error - should return original
856        let input = "limits cpu> 500m";
857        let parsed = styx_cst::parse(input);
858        assert!(!parsed.is_ok(), "should have parse error");
859        let output = format(input);
860        assert_eq!(output, input);
861    }
862
863    #[test]
864    fn test_syntax_error_space_before_gt() {
865        // Space before > is a syntax error - should return original
866        let input = "limits cpu >500m";
867        let parsed = styx_cst::parse(input);
868        assert!(!parsed.is_ok(), "should have parse error");
869        let output = format(input);
870        assert_eq!(output, input);
871    }
872
873    #[test]
874    fn test_tag_with_separate_sequence() {
875        // @a () has space between tag and sequence - must be preserved
876        // (whitespace affects parsing semantics for tag payloads)
877        let input = "@a ()";
878        let output = format(input);
879        assert_eq!(output.trim(), "@a ()");
880    }
881
882    #[test]
883    fn test_tag_with_attached_sequence() {
884        // @a() has no space - compact form must stay compact
885        let input = "@a()";
886        let output = format(input);
887        assert_eq!(output.trim(), "@a()");
888    }
889
890    // === Sequence comment tests ===
891
892    #[test]
893    fn test_multiline_sequence_preserves_structure() {
894        let input = r#"items (
895  a
896  b
897  c
898)"#;
899        let output = format(input);
900        insta::assert_snapshot!(output);
901    }
902
903    #[test]
904    fn test_sequence_with_trailing_comment() {
905        let input = r#"extends (
906  "@eslint/js:recommended"
907  typescript-eslint:strictTypeChecked
908  // don't fold
909)"#;
910        let output = format(input);
911        insta::assert_snapshot!(output);
912    }
913
914    #[test]
915    fn test_sequence_with_inline_comments() {
916        let input = r#"items (
917  // first item
918  a
919  // second item
920  b
921)"#;
922        let output = format(input);
923        insta::assert_snapshot!(output);
924    }
925
926    #[test]
927    fn test_sequence_comment_idempotent() {
928        let input = r#"extends (
929  "@eslint/js:recommended"
930  typescript-eslint:strictTypeChecked
931  // don't fold
932)"#;
933        let once = format(input);
934        let twice = format(&once);
935        assert_eq!(once, twice, "formatting should be idempotent");
936    }
937
938    #[test]
939    fn test_inline_sequence_stays_inline() {
940        // No newlines or comments = stays inline
941        let input = "items (a b c)";
942        let output = format(input);
943        assert_eq!(output.trim(), "items (a b c)");
944    }
945
946    #[test]
947    fn test_sequence_with_doc_comment() {
948        let input = r#"items (
949  /// Documentation for first
950  a
951  b
952)"#;
953        let output = format(input);
954        insta::assert_snapshot!(output);
955    }
956
957    #[test]
958    fn test_nested_multiline_sequence() {
959        let input = r#"outer (
960  (a b)
961  // between
962  (c d)
963)"#;
964        let output = format(input);
965        insta::assert_snapshot!(output);
966    }
967
968    #[test]
969    fn test_sequence_in_object_with_comment() {
970        let input = r#"config {
971  items (
972    a
973    // comment
974    b
975  )
976}"#;
977        let output = format(input);
978        insta::assert_snapshot!(output);
979    }
980
981    #[test]
982    fn test_object_with_only_comments() {
983        // Regression test: objects containing only comments should preserve them
984        let input = r#"pre-commit {
985    // generate-readmes false
986    // rustfmt false
987    // cargo-lock false
988}"#;
989        let output = format(input);
990        insta::assert_snapshot!(output);
991    }
992
993    #[test]
994    fn test_object_comments_with_blank_line() {
995        // Regression test: blank lines between comment groups should be preserved
996        let input = r#"config {
997    // first group
998    // still first group
999
1000    // second group after blank line
1001    // still second group
1002}"#;
1003        let output = format(input);
1004        insta::assert_snapshot!(output);
1005    }
1006
1007    #[test]
1008    fn test_object_mixed_entries_and_comments() {
1009        // Test mixing actual entries with commented-out entries
1010        let input = r#"settings {
1011    enabled true
1012    // disabled-option false
1013    name "test"
1014    // another-disabled option
1015}"#;
1016        let output = format(input);
1017        insta::assert_snapshot!(output);
1018    }
1019
1020    #[test]
1021    fn test_schema_with_doc_comments_in_inline_object() {
1022        // Regression test: doc comments inside an inline object must be preserved
1023        // and the object must be expanded to multiline format
1024        let input = include_str!("fixtures/before-format.styx");
1025        let output = format(input);
1026
1027        // The doc comments must be preserved
1028        assert!(
1029            output.contains("/// Features to use for clippy"),
1030            "Doc comment for clippy-features was lost!\nOutput:\n{}",
1031            output
1032        );
1033        assert!(
1034            output.contains("/// Features to use for docs"),
1035            "Doc comment for docs-features was lost!\nOutput:\n{}",
1036            output
1037        );
1038        assert!(
1039            output.contains("/// Features to use for doc tests"),
1040            "Doc comment for doc-test-features was lost!\nOutput:\n{}",
1041            output
1042        );
1043
1044        insta::assert_snapshot!(output);
1045    }
1046
1047    #[test]
1048    fn test_dibs_extracted_schema() {
1049        // Complex schema extracted from dibs binary - tests deeply nested structures
1050        let input = include_str!("fixtures/dibs-extracted.styx");
1051        let output = format(input);
1052        insta::assert_snapshot!(output);
1053    }
1054
1055    // ============================================================
1056    // SYSTEMATIC FORMATTER TESTS - 100 cases of increasing complexity
1057    // ============================================================
1058
1059    // --- 1-10: Basic scalars and simple entries ---
1060
1061    #[test]
1062    fn fmt_001_bare_scalar() {
1063        insta::assert_snapshot!(format("foo bar"));
1064    }
1065
1066    #[test]
1067    fn fmt_002_quoted_scalar() {
1068        insta::assert_snapshot!(format(r#"foo "hello world""#));
1069    }
1070
1071    #[test]
1072    fn fmt_003_raw_scalar() {
1073        insta::assert_snapshot!(format(r#"path r"/usr/bin""#));
1074    }
1075
1076    #[test]
1077    fn fmt_004_multiple_entries() {
1078        insta::assert_snapshot!(format("foo bar\nbaz qux"));
1079    }
1080
1081    #[test]
1082    fn fmt_005_unit_tag() {
1083        insta::assert_snapshot!(format("empty @"));
1084    }
1085
1086    #[test]
1087    fn fmt_006_simple_tag() {
1088        insta::assert_snapshot!(format("type @string"));
1089    }
1090
1091    #[test]
1092    fn fmt_007_tag_with_scalar_payload() {
1093        insta::assert_snapshot!(format(r#"default @default("hello")"#));
1094    }
1095
1096    #[test]
1097    fn fmt_008_nested_tags() {
1098        insta::assert_snapshot!(format("type @optional(@string)"));
1099    }
1100
1101    #[test]
1102    fn fmt_009_deeply_nested_tags() {
1103        insta::assert_snapshot!(format("type @seq(@optional(@string))"));
1104    }
1105
1106    #[test]
1107    fn fmt_010_path_syntax() {
1108        insta::assert_snapshot!(format("limits cpu>500m memory>256Mi"));
1109    }
1110
1111    // --- 11-20: Inline objects ---
1112
1113    #[test]
1114    fn fmt_011_empty_inline_object() {
1115        insta::assert_snapshot!(format("config {}"));
1116    }
1117
1118    #[test]
1119    fn fmt_012_single_entry_inline_object() {
1120        insta::assert_snapshot!(format("config {name foo}"));
1121    }
1122
1123    #[test]
1124    fn fmt_013_multi_entry_inline_object() {
1125        insta::assert_snapshot!(format("point {x 1, y 2, z 3}"));
1126    }
1127
1128    #[test]
1129    fn fmt_014_nested_inline_objects() {
1130        insta::assert_snapshot!(format("outer {inner {value 42}}"));
1131    }
1132
1133    #[test]
1134    fn fmt_015_inline_object_with_tags() {
1135        insta::assert_snapshot!(format("schema {name @string, age @int}"));
1136    }
1137
1138    #[test]
1139    fn fmt_016_tag_with_inline_object_payload() {
1140        insta::assert_snapshot!(format("type @object{name @string}"));
1141    }
1142
1143    #[test]
1144    fn fmt_017_inline_object_no_commas() {
1145        // Parser might accept this - test what formatter does
1146        insta::assert_snapshot!(format("config {a 1 b 2}"));
1147    }
1148
1149    #[test]
1150    fn fmt_018_inline_object_mixed_separators() {
1151        insta::assert_snapshot!(format("config {a 1, b 2 c 3}"));
1152    }
1153
1154    #[test]
1155    fn fmt_019_deeply_nested_inline() {
1156        insta::assert_snapshot!(format("a {b {c {d {e 1}}}}"));
1157    }
1158
1159    #[test]
1160    fn fmt_020_inline_with_unit_values() {
1161        insta::assert_snapshot!(format("flags {debug @, verbose @}"));
1162    }
1163
1164    // --- 21-30: Block objects ---
1165
1166    #[test]
1167    fn fmt_021_simple_block_object() {
1168        insta::assert_snapshot!(format("config {\n  name foo\n  value bar\n}"));
1169    }
1170
1171    #[test]
1172    fn fmt_022_block_object_irregular_indent() {
1173        insta::assert_snapshot!(format("config {\n    name foo\n  value bar\n}"));
1174    }
1175
1176    #[test]
1177    fn fmt_023_nested_block_objects() {
1178        insta::assert_snapshot!(format("outer {\n  inner {\n    value 42\n  }\n}"));
1179    }
1180
1181    #[test]
1182    fn fmt_024_block_with_inline_child() {
1183        insta::assert_snapshot!(format("config {\n  point {x 1, y 2}\n  name foo\n}"));
1184    }
1185
1186    #[test]
1187    fn fmt_025_inline_with_block_child() {
1188        // Inline object containing a block - should this expand?
1189        insta::assert_snapshot!(format("config {nested {\n  a 1\n}}"));
1190    }
1191
1192    #[test]
1193    fn fmt_026_block_object_blank_lines() {
1194        insta::assert_snapshot!(format("config {\n  a 1\n\n  b 2\n}"));
1195    }
1196
1197    #[test]
1198    fn fmt_027_block_object_multiple_blank_lines() {
1199        insta::assert_snapshot!(format("config {\n  a 1\n\n\n\n  b 2\n}"));
1200    }
1201
1202    #[test]
1203    fn fmt_028_empty_block_object() {
1204        insta::assert_snapshot!(format("config {\n}"));
1205    }
1206
1207    #[test]
1208    fn fmt_029_block_single_entry() {
1209        insta::assert_snapshot!(format("config {\n  only_one value\n}"));
1210    }
1211
1212    #[test]
1213    fn fmt_030_mixed_block_inline_siblings() {
1214        insta::assert_snapshot!(format("a {x 1}\nb {\n  y 2\n}"));
1215    }
1216
1217    // --- 31-40: Sequences ---
1218
1219    #[test]
1220    fn fmt_031_empty_sequence() {
1221        insta::assert_snapshot!(format("items ()"));
1222    }
1223
1224    #[test]
1225    fn fmt_032_single_item_sequence() {
1226        insta::assert_snapshot!(format("items (one)"));
1227    }
1228
1229    #[test]
1230    fn fmt_033_multi_item_sequence() {
1231        insta::assert_snapshot!(format("items (a b c d e)"));
1232    }
1233
1234    #[test]
1235    fn fmt_034_nested_sequences() {
1236        insta::assert_snapshot!(format("matrix ((1 2) (3 4))"));
1237    }
1238
1239    #[test]
1240    fn fmt_035_sequence_of_objects() {
1241        insta::assert_snapshot!(format("points ({x 1} {x 2})"));
1242    }
1243
1244    #[test]
1245    fn fmt_036_block_sequence() {
1246        insta::assert_snapshot!(format("items (\n  a\n  b\n  c\n)"));
1247    }
1248
1249    #[test]
1250    fn fmt_037_sequence_with_trailing_newline() {
1251        insta::assert_snapshot!(format("items (a b c\n)"));
1252    }
1253
1254    #[test]
1255    fn fmt_038_tag_with_sequence_payload() {
1256        insta::assert_snapshot!(format("type @seq(a b c)"));
1257    }
1258
1259    #[test]
1260    fn fmt_039_tag_sequence_attached() {
1261        insta::assert_snapshot!(format("type @seq()"));
1262    }
1263
1264    #[test]
1265    fn fmt_040_tag_sequence_detached() {
1266        insta::assert_snapshot!(format("type @seq ()"));
1267    }
1268
1269    // --- 41-50: Comments ---
1270
1271    #[test]
1272    fn fmt_041_line_comment_before_entry() {
1273        insta::assert_snapshot!(format("// comment\nfoo bar"));
1274    }
1275
1276    #[test]
1277    fn fmt_042_doc_comment_before_entry() {
1278        insta::assert_snapshot!(format("/// doc comment\nfoo bar"));
1279    }
1280
1281    #[test]
1282    fn fmt_043_comment_inside_block_object() {
1283        insta::assert_snapshot!(format("config {\n  // comment\n  foo bar\n}"));
1284    }
1285
1286    #[test]
1287    fn fmt_044_doc_comment_inside_block_object() {
1288        insta::assert_snapshot!(format("config {\n  /// doc\n  foo bar\n}"));
1289    }
1290
1291    #[test]
1292    fn fmt_045_comment_between_entries() {
1293        insta::assert_snapshot!(format("config {\n  a 1\n  // middle\n  b 2\n}"));
1294    }
1295
1296    #[test]
1297    fn fmt_046_comment_at_end_of_object() {
1298        insta::assert_snapshot!(format("config {\n  a 1\n  // trailing\n}"));
1299    }
1300
1301    #[test]
1302    fn fmt_047_inline_object_with_doc_comment() {
1303        // Doc comment forces expansion
1304        insta::assert_snapshot!(format("config {/// doc\na 1, b 2}"));
1305    }
1306
1307    #[test]
1308    fn fmt_048_comment_in_sequence() {
1309        insta::assert_snapshot!(format("items (\n  // comment\n  a\n  b\n)"));
1310    }
1311
1312    #[test]
1313    fn fmt_049_multiple_comments_grouped() {
1314        insta::assert_snapshot!(format("config {\n  // first\n  // second\n  a 1\n}"));
1315    }
1316
1317    #[test]
1318    fn fmt_050_comments_with_blank_line_between() {
1319        insta::assert_snapshot!(format("config {\n  // group 1\n\n  // group 2\n  a 1\n}"));
1320    }
1321
1322    // --- 51-60: The problematic cases from styx extract ---
1323
1324    #[test]
1325    fn fmt_051_optional_with_newline_before_close() {
1326        // This is the minimal repro of the dibs issue
1327        insta::assert_snapshot!(format("foo @optional(@string\n)"));
1328    }
1329
1330    #[test]
1331    fn fmt_052_seq_with_newline_before_close() {
1332        insta::assert_snapshot!(format("foo @seq(@string\n)"));
1333    }
1334
1335    #[test]
1336    fn fmt_053_object_with_newline_before_close() {
1337        insta::assert_snapshot!(format("foo @object{a @string\n}"));
1338    }
1339
1340    #[test]
1341    fn fmt_054_deeply_nested_with_weird_breaks() {
1342        insta::assert_snapshot!(format("foo @optional(@object{a @seq(@string\n)\n})"));
1343    }
1344
1345    #[test]
1346    fn fmt_055_closing_delimiters_on_own_lines() {
1347        insta::assert_snapshot!(format("foo @a(@b{x 1\n}\n)"));
1348    }
1349
1350    #[test]
1351    fn fmt_056_inline_entries_one_has_doc_comment() {
1352        // If ANY entry has doc comment, whole object should be block
1353        insta::assert_snapshot!(format("config {a @unit, /// doc\nb @unit, c @unit}"));
1354    }
1355
1356    #[test]
1357    fn fmt_057_mixed_inline_block_with_doc() {
1358        insta::assert_snapshot!(format("schema {@ @object{a @unit, /// doc\nb @string}}"));
1359    }
1360
1361    #[test]
1362    fn fmt_058_tag_map_with_doc_comments() {
1363        insta::assert_snapshot!(format(
1364            "fields @map(@string@enum{/// variant a\na @unit, /// variant b\nb @unit})"
1365        ));
1366    }
1367
1368    #[test]
1369    fn fmt_059_nested_enums_with_docs() {
1370        insta::assert_snapshot!(format(
1371            "type @enum{/// first\na @object{/// inner\nx @int}, b @unit}"
1372        ));
1373    }
1374
1375    #[test]
1376    fn fmt_060_the_dibs_pattern() {
1377        // Simplified version of dibs schema structure
1378        insta::assert_snapshot!(format(
1379            r#"schema {@ @object{decls @map(@string@enum{
1380    /// A query
1381    query @object{
1382        params @optional(@object{params @map(@string@enum{uuid @unit, /// doc
1383            optional @seq(@type{name T})
1384        })})
1385    }
1386})}}"#
1387        ));
1388    }
1389
1390    // --- 61-70: Top-level spacing (issue #28) ---
1391
1392    #[test]
1393    fn fmt_061_two_inline_entries() {
1394        insta::assert_snapshot!(format("a 1\nb 2"));
1395    }
1396
1397    #[test]
1398    fn fmt_062_two_block_entries() {
1399        insta::assert_snapshot!(format("a {\n  x 1\n}\nb {\n  y 2\n}"));
1400    }
1401
1402    #[test]
1403    fn fmt_063_inline_then_block() {
1404        insta::assert_snapshot!(format("a 1\nb {\n  y 2\n}"));
1405    }
1406
1407    #[test]
1408    fn fmt_064_block_then_inline() {
1409        insta::assert_snapshot!(format("a {\n  x 1\n}\nb 2"));
1410    }
1411
1412    #[test]
1413    fn fmt_065_inline_inline_with_existing_blank() {
1414        insta::assert_snapshot!(format("a 1\n\nb 2"));
1415    }
1416
1417    #[test]
1418    fn fmt_066_three_entries_mixed() {
1419        insta::assert_snapshot!(format("a 1\nb {\n  x 1\n}\nc 3"));
1420    }
1421
1422    #[test]
1423    fn fmt_067_meta_then_schema_blocks() {
1424        insta::assert_snapshot!(format("meta {\n  id test\n}\nschema {\n  @ @string\n}"));
1425    }
1426
1427    #[test]
1428    fn fmt_068_doc_comment_entry_spacing() {
1429        insta::assert_snapshot!(format("/// doc for a\na 1\n/// doc for b\nb 2"));
1430    }
1431
1432    #[test]
1433    fn fmt_069_multiple_blocks_no_blanks() {
1434        insta::assert_snapshot!(format("a {\nx 1\n}\nb {\ny 2\n}\nc {\nz 3\n}"));
1435    }
1436
1437    #[test]
1438    fn fmt_070_schema_declaration_spacing() {
1439        insta::assert_snapshot!(format("@schema foo.styx\nname test"));
1440    }
1441
1442    // --- 71-80: Edge cases with tags ---
1443
1444    #[test]
1445    fn fmt_071_tag_chain() {
1446        insta::assert_snapshot!(format("type @optional @string"));
1447    }
1448
1449    #[test]
1450    fn fmt_072_tag_with_object_then_scalar() {
1451        insta::assert_snapshot!(format("type @default({x 1} @object{x @int})"));
1452    }
1453
1454    #[test]
1455    fn fmt_073_multiple_tags_same_entry() {
1456        insta::assert_snapshot!(format("field @deprecated @optional(@string)"));
1457    }
1458
1459    #[test]
1460    fn fmt_074_tag_payload_is_unit() {
1461        insta::assert_snapshot!(format("empty @some(@)"));
1462    }
1463
1464    #[test]
1465    fn fmt_075_tag_with_heredoc() {
1466        insta::assert_snapshot!(format("sql @raw(<<EOF\nSELECT *\nEOF)"));
1467    }
1468
1469    #[test]
1470    fn fmt_076_tag_payload_sequence_of_tags() {
1471        insta::assert_snapshot!(format("types @union(@string @int @bool)"));
1472    }
1473
1474    #[test]
1475    fn fmt_077_tag_map_compact() {
1476        insta::assert_snapshot!(format("fields @map(@string@int)"));
1477    }
1478
1479    #[test]
1480    fn fmt_078_tag_map_with_complex_value() {
1481        insta::assert_snapshot!(format("fields @map(@string@object{x @int, y @int})"));
1482    }
1483
1484    #[test]
1485    fn fmt_079_tag_type_reference() {
1486        insta::assert_snapshot!(format("field @type{name MyType}"));
1487    }
1488
1489    #[test]
1490    fn fmt_080_tag_default_with_at() {
1491        insta::assert_snapshot!(format("opt @default(@ @optional(@string))"));
1492    }
1493
1494    // --- 81-90: Heredocs ---
1495
1496    #[test]
1497    fn fmt_081_simple_heredoc() {
1498        insta::assert_snapshot!(format("text <<EOF\nhello\nworld\nEOF"));
1499    }
1500
1501    #[test]
1502    fn fmt_082_heredoc_in_object() {
1503        insta::assert_snapshot!(format("config {\n  sql <<SQL\nSELECT *\nSQL\n}"));
1504    }
1505
1506    #[test]
1507    fn fmt_083_heredoc_indented_content() {
1508        insta::assert_snapshot!(format("code <<END\n  indented\n    more\nEND"));
1509    }
1510
1511    #[test]
1512    fn fmt_084_multiple_heredocs() {
1513        insta::assert_snapshot!(format("a <<A\nfirst\nA\nb <<B\nsecond\nB"));
1514    }
1515
1516    #[test]
1517    fn fmt_085_heredoc_empty() {
1518        insta::assert_snapshot!(format("empty <<EOF\nEOF"));
1519    }
1520
1521    // --- 86-90: Quoted strings edge cases ---
1522
1523    #[test]
1524    fn fmt_086_quoted_with_escapes() {
1525        insta::assert_snapshot!(format(r#"msg "hello\nworld\ttab""#));
1526    }
1527
1528    #[test]
1529    fn fmt_087_quoted_with_quotes() {
1530        insta::assert_snapshot!(format(r#"msg "say \"hello\"""#));
1531    }
1532
1533    #[test]
1534    fn fmt_088_raw_string_with_hashes() {
1535        insta::assert_snapshot!(format(r##"pattern r#"foo"bar"#"##));
1536    }
1537
1538    #[test]
1539    fn fmt_089_quoted_empty() {
1540        insta::assert_snapshot!(format(r#"empty """#));
1541    }
1542
1543    #[test]
1544    fn fmt_090_mixed_scalar_types() {
1545        insta::assert_snapshot!(format(r#"config {bare word, quoted "str", raw r"path"}"#));
1546    }
1547
1548    // --- 91-100: Complex real-world-like structures ---
1549
1550    #[test]
1551    fn fmt_091_schema_with_meta() {
1552        insta::assert_snapshot!(format(
1553            r#"meta {id "app:config@1", cli myapp}
1554schema {@ @object{
1555    name @string
1556    port @default(8080 @int)
1557}}"#
1558        ));
1559    }
1560
1561    #[test]
1562    fn fmt_092_enum_with_object_variants() {
1563        insta::assert_snapshot!(format(
1564            r#"type @enum{
1565    /// A simple variant
1566    simple @unit
1567    /// Complex variant
1568    complex @object{x @int, y @int}
1569}"#
1570        ));
1571    }
1572
1573    #[test]
1574    fn fmt_093_nested_optionals() {
1575        insta::assert_snapshot!(format("type @optional(@optional(@optional(@string)))"));
1576    }
1577
1578    #[test]
1579    fn fmt_094_map_of_maps() {
1580        insta::assert_snapshot!(format("data @map(@string@map(@string@int))"));
1581    }
1582
1583    #[test]
1584    fn fmt_095_sequence_of_enums() {
1585        insta::assert_snapshot!(format("items @seq(@enum{a @unit, b @unit, c @unit})"));
1586    }
1587
1588    #[test]
1589    fn fmt_096_all_builtin_types() {
1590        insta::assert_snapshot!(format(
1591            "types {s @string, i @int, b @bool, f @float, u @unit}"
1592        ));
1593    }
1594
1595    #[test]
1596    fn fmt_097_deep_nesting_mixed() {
1597        insta::assert_snapshot!(format(
1598            "a @object{b @seq(@enum{c @object{d @optional(@map(@string@int))}})}"
1599        ));
1600    }
1601
1602    #[test]
1603    fn fmt_098_realistic_config_schema() {
1604        insta::assert_snapshot!(format(
1605            r#"meta {id "crate:myapp@1", cli myapp, description "My application config"}
1606schema {@ @object{
1607    /// Server configuration
1608    server @object{
1609        /// Hostname to bind
1610        host @default("localhost" @string)
1611        /// Port number
1612        port @default(8080 @int)
1613    }
1614    /// Database settings
1615    database @optional(@object{
1616        url @string
1617        pool_size @default(10 @int)
1618    })
1619}}"#
1620        ));
1621    }
1622
1623    #[test]
1624    fn fmt_099_attributes_syntax() {
1625        insta::assert_snapshot!(format("resource limits>cpu>500m limits>memory>256Mi"));
1626    }
1627
1628    #[test]
1629    fn fmt_100_everything_combined() {
1630        insta::assert_snapshot!(format(
1631            r#"// Top level comment
1632meta {id "test@1"}
1633
1634/// Schema documentation
1635schema {@ @object{
1636    /// A string field
1637    name @string
1638
1639    /// An enum with variants
1640    kind @enum{
1641        /// Simple kind
1642        simple @unit
1643        /// Complex kind
1644        complex @object{
1645            /// Nested value
1646            value @optional(@int)
1647        }
1648    }
1649
1650    /// A sequence
1651    items @seq(@string)
1652
1653    /// A map
1654    data @map(@string@object{x @int, y @int})
1655}}"#
1656        ));
1657    }
1658}
1659
1660#[cfg(test)]
1661mod proptests {
1662    use super::*;
1663    use proptest::prelude::*;
1664
1665    /// Generate a valid bare scalar (no special chars)
1666    fn bare_scalar() -> impl Strategy<Value = String> {
1667        // Start with letter, then alphanumeric + some allowed chars
1668        prop::string::string_regex("[a-zA-Z][a-zA-Z0-9_-]{0,10}")
1669            .unwrap()
1670            .prop_filter("non-empty", |s| !s.is_empty())
1671    }
1672
1673    /// Generate a quoted scalar with potential escape sequences
1674    fn quoted_scalar() -> impl Strategy<Value = String> {
1675        prop_oneof![
1676            // Simple quoted string
1677            prop::string::string_regex(r#"[a-zA-Z0-9 _-]{0,20}"#)
1678                .unwrap()
1679                .prop_map(|s| format!("\"{}\"", s)),
1680            // With common escapes
1681            prop::string::string_regex(r#"[a-zA-Z0-9 ]{0,10}"#)
1682                .unwrap()
1683                .prop_map(|s| format!("\"hello\\n{}\\t\"", s)),
1684        ]
1685    }
1686
1687    /// Generate a raw scalar (r"..." or r#"..."#)
1688    fn raw_scalar() -> impl Strategy<Value = String> {
1689        prop_oneof![
1690            // Simple raw string
1691            prop::string::string_regex(r#"[a-zA-Z0-9/_\\.-]{0,15}"#)
1692                .unwrap()
1693                .prop_map(|s| format!("r\"{}\"", s)),
1694            // Raw string with # delimiters (can contain quotes)
1695            prop::string::string_regex(r#"[a-zA-Z0-9 "/_\\.-]{0,15}"#)
1696                .unwrap()
1697                .prop_map(|s| format!("r#\"{}\"#", s)),
1698        ]
1699    }
1700
1701    /// Generate a scalar (bare, quoted, or raw)
1702    fn scalar() -> impl Strategy<Value = String> {
1703        prop_oneof![
1704            4 => bare_scalar(),
1705            3 => quoted_scalar(),
1706            1 => raw_scalar(),
1707        ]
1708    }
1709
1710    /// Generate a tag name
1711    fn tag_name() -> impl Strategy<Value = String> {
1712        prop::string::string_regex("[a-zA-Z][a-zA-Z0-9_-]{0,8}")
1713            .unwrap()
1714            .prop_filter("non-empty", |s| !s.is_empty())
1715    }
1716
1717    /// Generate a tag (@name or @name with payload)
1718    /// Per grammar: TagPayload must be immediately attached (no whitespace)
1719    /// TagPayload ::= Object | Sequence | QuotedScalar | RawScalar | HeredocScalar | '@'
1720    /// Note: BareScalar is NOT a valid TagPayload
1721    fn tag() -> impl Strategy<Value = String> {
1722        prop_oneof![
1723            // Unit tag (just @)
1724            Just("@".to_string()),
1725            // Simple tag (no payload)
1726            tag_name().prop_map(|n| format!("@{n}")),
1727            // Tag with sequence payload (must be attached, no space)
1728            (tag_name(), flat_sequence()).prop_map(|(n, s)| format!("@{n}{s}")),
1729            // Tag with inline object payload (must be attached, no space)
1730            (tag_name(), inline_object()).prop_map(|(n, o)| format!("@{n}{o}")),
1731            // Tag with quoted scalar payload (must be attached, no space)
1732            (tag_name(), quoted_scalar()).prop_map(|(n, q)| format!("@{n}{q}")),
1733            // Tag with unit payload
1734            (tag_name()).prop_map(|n| format!("@{n} @")),
1735        ]
1736    }
1737
1738    /// Generate an attribute (key>value)
1739    fn attribute() -> impl Strategy<Value = String> {
1740        (bare_scalar(), scalar()).prop_map(|(k, v)| format!("{k}>{v}"))
1741    }
1742
1743    /// Generate a flat sequence of scalars (no nesting)
1744    fn flat_sequence() -> impl Strategy<Value = String> {
1745        prop::collection::vec(scalar(), 0..5).prop_map(|items| {
1746            if items.is_empty() {
1747                "()".to_string()
1748            } else {
1749                format!("({})", items.join(" "))
1750            }
1751        })
1752    }
1753
1754    /// Generate a nested sequence like ((a b) (c d))
1755    fn nested_sequence() -> impl Strategy<Value = String> {
1756        prop::collection::vec(flat_sequence(), 1..4)
1757            .prop_map(|seqs| format!("({})", seqs.join(" ")))
1758    }
1759
1760    /// Generate a sequence (flat or nested)
1761    fn sequence() -> impl Strategy<Value = String> {
1762        prop_oneof![
1763            3 => flat_sequence(),
1764            1 => nested_sequence(),
1765        ]
1766    }
1767
1768    /// Generate an inline object {key value, ...}
1769    fn inline_object() -> impl Strategy<Value = String> {
1770        prop::collection::vec((bare_scalar(), scalar()), 0..4).prop_map(|entries| {
1771            if entries.is_empty() {
1772                "{}".to_string()
1773            } else {
1774                let inner: Vec<String> = entries
1775                    .into_iter()
1776                    .map(|(k, v)| format!("{k} {v}"))
1777                    .collect();
1778                format!("{{{}}}", inner.join(", "))
1779            }
1780        })
1781    }
1782
1783    /// Generate a multiline object
1784    fn multiline_object() -> impl Strategy<Value = String> {
1785        prop::collection::vec((bare_scalar(), scalar()), 1..4).prop_map(|entries| {
1786            let inner: Vec<String> = entries
1787                .into_iter()
1788                .map(|(k, v)| format!("  {k} {v}"))
1789                .collect();
1790            format!("{{\n{}\n}}", inner.join("\n"))
1791        })
1792    }
1793
1794    /// Generate a line comment
1795    fn line_comment() -> impl Strategy<Value = String> {
1796        prop::string::string_regex("[a-zA-Z0-9 _-]{0,30}")
1797            .unwrap()
1798            .prop_map(|s| format!("// {}", s.trim()))
1799    }
1800
1801    /// Generate a doc comment
1802    fn doc_comment() -> impl Strategy<Value = String> {
1803        prop::string::string_regex("[a-zA-Z0-9 _-]{0,30}")
1804            .unwrap()
1805            .prop_map(|s| format!("/// {}", s.trim()))
1806    }
1807
1808    /// Generate a heredoc
1809    fn heredoc() -> impl Strategy<Value = String> {
1810        let delimiters = prop_oneof![
1811            Just("EOF".to_string()),
1812            Just("END".to_string()),
1813            Just("TEXT".to_string()),
1814            Just("CODE".to_string()),
1815        ];
1816        let content = prop::string::string_regex("[a-zA-Z0-9 \n_.-]{0,50}").unwrap();
1817        let lang_hint = prop_oneof![
1818            Just("".to_string()),
1819            Just(",txt".to_string()),
1820            Just(",rust".to_string()),
1821        ];
1822        (delimiters, content, lang_hint)
1823            .prop_map(|(delim, content, hint)| format!("<<{delim}{hint}\n{content}\n{delim}"))
1824    }
1825
1826    /// Generate a simple value (scalar, sequence, or attributes)
1827    fn simple_value() -> impl Strategy<Value = String> {
1828        prop_oneof![
1829            3 => scalar(),
1830            2 => sequence(),
1831            2 => tag(),
1832            1 => inline_object(),
1833            1 => multiline_object(),
1834            1 => heredoc(),
1835            // Multiple attributes (path syntax)
1836            1 => prop::collection::vec(attribute(), 1..4).prop_map(|attrs| attrs.join(" ")),
1837        ]
1838    }
1839
1840    /// Generate a simple entry (key value)
1841    fn entry() -> impl Strategy<Value = String> {
1842        prop_oneof![
1843            // Regular entry
1844            (bare_scalar(), simple_value()).prop_map(|(k, v)| format!("{k} {v}")),
1845            // Tag as key
1846            (tag(), simple_value()).prop_map(|(t, v)| format!("{t} {v}")),
1847        ]
1848    }
1849
1850    /// Generate an entry optionally preceded by a comment
1851    fn commented_entry() -> impl Strategy<Value = String> {
1852        prop_oneof![
1853            3 => entry(),
1854            1 => (doc_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
1855            1 => (line_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
1856        ]
1857    }
1858
1859    /// Generate a simple document (multiple entries)
1860    fn document() -> impl Strategy<Value = String> {
1861        prop::collection::vec(commented_entry(), 1..5).prop_map(|entries| entries.join("\n"))
1862    }
1863
1864    /// Generate a deeply nested object (recursive)
1865    fn deep_object(depth: usize) -> BoxedStrategy<String> {
1866        if depth == 0 {
1867            scalar().boxed()
1868        } else {
1869            prop_oneof![
1870                // Scalar leaf
1871                2 => scalar(),
1872                // Nested object
1873                1 => prop::collection::vec(
1874                    (bare_scalar(), deep_object(depth - 1)),
1875                    1..3
1876                ).prop_map(|entries| {
1877                    let inner: Vec<String> = entries.into_iter()
1878                        .map(|(k, v)| format!("  {k} {v}"))
1879                        .collect();
1880                    format!("{{\n{}\n}}", inner.join("\n"))
1881                }),
1882            ]
1883            .boxed()
1884        }
1885    }
1886
1887    /// Generate a sequence containing tags
1888    fn sequence_of_tags() -> impl Strategy<Value = String> {
1889        prop::collection::vec(tag(), 1..5).prop_map(|tags| format!("({})", tags.join(" ")))
1890    }
1891
1892    /// Generate an object with sequence values
1893    fn object_with_sequences() -> impl Strategy<Value = String> {
1894        prop::collection::vec((bare_scalar(), flat_sequence()), 1..4).prop_map(|entries| {
1895            let inner: Vec<String> = entries
1896                .into_iter()
1897                .map(|(k, v)| format!("  {k} {v}"))
1898                .collect();
1899            format!("{{\n{}\n}}", inner.join("\n"))
1900        })
1901    }
1902
1903    /// Strip spans from a value tree for comparison (spans change after formatting)
1904    fn strip_spans(value: &mut styx_tree::Value) {
1905        value.span = None;
1906        if let Some(ref mut tag) = value.tag {
1907            tag.span = None;
1908        }
1909        if let Some(ref mut payload) = value.payload {
1910            match payload {
1911                styx_tree::Payload::Scalar(s) => s.span = None,
1912                styx_tree::Payload::Sequence(seq) => {
1913                    seq.span = None;
1914                    for item in &mut seq.items {
1915                        strip_spans(item);
1916                    }
1917                }
1918                styx_tree::Payload::Object(obj) => {
1919                    obj.span = None;
1920                    for entry in &mut obj.entries {
1921                        strip_spans(&mut entry.key);
1922                        strip_spans(&mut entry.value);
1923                    }
1924                }
1925            }
1926        }
1927    }
1928
1929    /// Parse source into a comparable tree (spans stripped)
1930    fn parse_to_tree(source: &str) -> Option<styx_tree::Value> {
1931        let mut value = styx_tree::parse(source).ok()?;
1932        strip_spans(&mut value);
1933        Some(value)
1934    }
1935
1936    proptest! {
1937        /// Formatting must preserve document semantics
1938        #[test]
1939        fn format_preserves_semantics(input in document()) {
1940            let tree1 = parse_to_tree(&input);
1941
1942            // Skip if original doesn't parse (shouldn't happen with our generator)
1943            if tree1.is_none() {
1944                return Ok(());
1945            }
1946            let tree1 = tree1.unwrap();
1947
1948            let formatted = format_source(&input, FormatOptions::default());
1949            let tree2 = parse_to_tree(&formatted);
1950
1951            prop_assert!(
1952                tree2.is_some(),
1953                "Formatted output should parse. Input:\n{}\nFormatted:\n{}",
1954                input,
1955                formatted
1956            );
1957            let tree2 = tree2.unwrap();
1958
1959            prop_assert_eq!(
1960                tree1,
1961                tree2,
1962                "Formatting changed semantics!\nInput:\n{}\nFormatted:\n{}",
1963                input,
1964                formatted
1965            );
1966        }
1967
1968        /// Formatting should be idempotent
1969        #[test]
1970        fn format_is_idempotent(input in document()) {
1971            let once = format_source(&input, FormatOptions::default());
1972            let twice = format_source(&once, FormatOptions::default());
1973
1974            prop_assert_eq!(
1975                &once,
1976                &twice,
1977                "Formatting is not idempotent!\nInput:\n{}\nOnce:\n{}\nTwice:\n{}",
1978                input,
1979                &once,
1980                &twice
1981            );
1982        }
1983
1984        /// Deeply nested objects should format correctly
1985        #[test]
1986        fn format_deep_objects(key in bare_scalar(), value in deep_object(4)) {
1987            let input = format!("{key} {value}");
1988            let tree1 = parse_to_tree(&input);
1989
1990            if tree1.is_none() {
1991                return Ok(());
1992            }
1993            let tree1 = tree1.unwrap();
1994
1995            let formatted = format_source(&input, FormatOptions::default());
1996            let tree2 = parse_to_tree(&formatted);
1997
1998            prop_assert!(
1999                tree2.is_some(),
2000                "Deep object should parse after formatting. Input:\n{}\nFormatted:\n{}",
2001                input,
2002                formatted
2003            );
2004
2005            prop_assert_eq!(
2006                tree1,
2007                tree2.unwrap(),
2008                "Deep object semantics changed!\nInput:\n{}\nFormatted:\n{}",
2009                input,
2010                formatted
2011            );
2012        }
2013
2014        /// Sequences of tags should format correctly
2015        #[test]
2016        fn format_sequence_of_tags(key in bare_scalar(), seq in sequence_of_tags()) {
2017            let input = format!("{key} {seq}");
2018            let tree1 = parse_to_tree(&input);
2019
2020            if tree1.is_none() {
2021                return Ok(());
2022            }
2023            let tree1 = tree1.unwrap();
2024
2025            let formatted = format_source(&input, FormatOptions::default());
2026            let tree2 = parse_to_tree(&formatted);
2027
2028            prop_assert!(
2029                tree2.is_some(),
2030                "Tag sequence should parse. Input:\n{}\nFormatted:\n{}",
2031                input,
2032                formatted
2033            );
2034
2035            prop_assert_eq!(
2036                tree1,
2037                tree2.unwrap(),
2038                "Tag sequence semantics changed!\nInput:\n{}\nFormatted:\n{}",
2039                input,
2040                formatted
2041            );
2042        }
2043
2044        /// Objects containing sequences should format correctly
2045        #[test]
2046        fn format_objects_with_sequences(key in bare_scalar(), obj in object_with_sequences()) {
2047            let input = format!("{key} {obj}");
2048            let tree1 = parse_to_tree(&input);
2049
2050            if tree1.is_none() {
2051                return Ok(());
2052            }
2053            let tree1 = tree1.unwrap();
2054
2055            let formatted = format_source(&input, FormatOptions::default());
2056            let tree2 = parse_to_tree(&formatted);
2057
2058            prop_assert!(
2059                tree2.is_some(),
2060                "Object with sequences should parse. Input:\n{}\nFormatted:\n{}",
2061                input,
2062                formatted
2063            );
2064
2065            prop_assert_eq!(
2066                tree1,
2067                tree2.unwrap(),
2068                "Object with sequences semantics changed!\nInput:\n{}\nFormatted:\n{}",
2069                input,
2070                formatted
2071            );
2072        }
2073
2074        /// Formatting must preserve all comments (line and doc comments)
2075        #[test]
2076        fn format_preserves_comments(input in document_with_comments()) {
2077            let original_comments = extract_comments(&input);
2078
2079            // Skip if no comments (not interesting for this test)
2080            if original_comments.is_empty() {
2081                return Ok(());
2082            }
2083
2084            let formatted = format_source(&input, FormatOptions::default());
2085            let formatted_comments = extract_comments(&formatted);
2086
2087            prop_assert_eq!(
2088                original_comments.len(),
2089                formatted_comments.len(),
2090                "Comment count changed!\nInput ({} comments):\n{}\nFormatted ({} comments):\n{}\nOriginal comments: {:?}\nFormatted comments: {:?}",
2091                original_comments.len(),
2092                input,
2093                formatted_comments.len(),
2094                formatted,
2095                original_comments,
2096                formatted_comments
2097            );
2098
2099            // Check that each comment text is preserved (order may change slightly due to formatting)
2100            for comment in &original_comments {
2101                prop_assert!(
2102                    formatted_comments.contains(comment),
2103                    "Comment lost during formatting!\nMissing: {:?}\nInput:\n{}\nFormatted:\n{}\nOriginal comments: {:?}\nFormatted comments: {:?}",
2104                    comment,
2105                    input,
2106                    formatted,
2107                    original_comments,
2108                    formatted_comments
2109                );
2110            }
2111        }
2112
2113        /// Objects with only comments should preserve them
2114        #[test]
2115        fn format_preserves_comments_in_empty_objects(
2116            key in bare_scalar(),
2117            comments in prop::collection::vec(line_comment(), 1..5)
2118        ) {
2119            let inner = comments.iter()
2120                .map(|c| format!("    {c}"))
2121                .collect::<Vec<_>>()
2122                .join("\n");
2123            let input = format!("{key} {{\n{inner}\n}}");
2124
2125            let original_comments = extract_comments(&input);
2126            let formatted = format_source(&input, FormatOptions::default());
2127            let formatted_comments = extract_comments(&formatted);
2128
2129            prop_assert_eq!(
2130                original_comments.len(),
2131                formatted_comments.len(),
2132                "Comments in empty object lost!\nInput:\n{}\nFormatted:\n{}",
2133                input,
2134                formatted
2135            );
2136        }
2137
2138        /// Objects with mixed entries and comments should preserve all comments
2139        #[test]
2140        fn format_preserves_comments_mixed_with_entries(
2141            key in bare_scalar(),
2142            items in prop::collection::vec(
2143                prop_oneof![
2144                    // Entry
2145                    (bare_scalar(), scalar()).prop_map(|(k, v)| format!("{k} {v}")),
2146                    // Comment
2147                    line_comment(),
2148                ],
2149                2..6
2150            )
2151        ) {
2152            let inner = items.iter()
2153                .map(|item| format!("    {item}"))
2154                .collect::<Vec<_>>()
2155                .join("\n");
2156            let input = format!("{key} {{\n{inner}\n}}");
2157
2158            let original_comments = extract_comments(&input);
2159            let formatted = format_source(&input, FormatOptions::default());
2160            let formatted_comments = extract_comments(&formatted);
2161
2162            prop_assert_eq!(
2163                original_comments.len(),
2164                formatted_comments.len(),
2165                "Comments mixed with entries lost!\nInput:\n{}\nFormatted:\n{}\nOriginal: {:?}\nFormatted: {:?}",
2166                input,
2167                formatted,
2168                original_comments,
2169                formatted_comments
2170            );
2171        }
2172
2173        /// Sequences with comments should preserve them
2174        #[test]
2175        fn format_preserves_comments_in_sequences(
2176            key in bare_scalar(),
2177            items in prop::collection::vec(
2178                prop_oneof![
2179                    // Scalar item
2180                    2 => scalar(),
2181                    // Comment
2182                    1 => line_comment(),
2183                ],
2184                2..6
2185            )
2186        ) {
2187            // Only create multiline sequence if we have comments
2188            let has_comment = items.iter().any(|i| i.starts_with("//"));
2189            if !has_comment {
2190                return Ok(());
2191            }
2192
2193            let inner = items.iter()
2194                .map(|item| format!("    {item}"))
2195                .collect::<Vec<_>>()
2196                .join("\n");
2197            let input = format!("{key} (\n{inner}\n)");
2198
2199            let original_comments = extract_comments(&input);
2200            let formatted = format_source(&input, FormatOptions::default());
2201            let formatted_comments = extract_comments(&formatted);
2202
2203            prop_assert_eq!(
2204                original_comments.len(),
2205                formatted_comments.len(),
2206                "Comments in sequence lost!\nInput:\n{}\nFormatted:\n{}\nOriginal: {:?}\nFormatted: {:?}",
2207                input,
2208                formatted,
2209                original_comments,
2210                formatted_comments
2211            );
2212        }
2213    }
2214
2215    /// Generate a document that definitely contains comments in various positions
2216    fn document_with_comments() -> impl Strategy<Value = String> {
2217        prop::collection::vec(
2218            prop_oneof![
2219                // Regular entry
2220                2 => entry(),
2221                // Entry preceded by comment
2222                2 => (line_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
2223                // Entry preceded by doc comment
2224                1 => (doc_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
2225                // Object with comments inside
2226                1 => object_with_internal_comments(),
2227            ],
2228            1..5,
2229        )
2230        .prop_map(|entries| entries.join("\n"))
2231    }
2232
2233    /// Generate an object that has comments inside it
2234    fn object_with_internal_comments() -> impl Strategy<Value = String> {
2235        (
2236            bare_scalar(),
2237            prop::collection::vec(
2238                prop_oneof![
2239                    // Entry
2240                    2 => (bare_scalar(), scalar()).prop_map(|(k, v)| format!("{k} {v}")),
2241                    // Comment
2242                    1 => line_comment(),
2243                ],
2244                1..5,
2245            ),
2246        )
2247            .prop_map(|(key, items)| {
2248                let inner = items
2249                    .iter()
2250                    .map(|item| format!("    {item}"))
2251                    .collect::<Vec<_>>()
2252                    .join("\n");
2253                format!("{key} {{\n{inner}\n}}")
2254            })
2255    }
2256
2257    /// Extract all comments from source text (both line and doc comments)
2258    fn extract_comments(source: &str) -> Vec<String> {
2259        let mut comments = Vec::new();
2260        for line in source.lines() {
2261            let trimmed = line.trim();
2262            if trimmed.starts_with("///") || trimmed.starts_with("//") {
2263                comments.push(trimmed.to_string());
2264            }
2265        }
2266        comments
2267    }
2268}
2269
2270#[cfg(test)]
2271mod consecutive_doc_comment_tests {
2272    use super::*;
2273
2274    fn format(source: &str) -> String {
2275        format_source(source, FormatOptions::default())
2276    }
2277
2278    #[test]
2279    fn test_consecutive_doc_comments_no_blank_line() {
2280        // Bug: consecutive doc comments get a blank line inserted between them
2281        let input = r#"/// First line of doc
2282/// Second line of doc
2283entry value
2284"#;
2285        let output = format(input);
2286        // The two doc comments should stay together without a blank line
2287        assert!(
2288            !output.contains("/// First line of doc\n\n/// Second line of doc"),
2289            "Consecutive doc comments should not have a blank line between them!\nOutput:\n{}",
2290            output
2291        );
2292        // They should be on consecutive lines
2293        assert!(
2294            output.contains("/// First line of doc\n/// Second line of doc"),
2295            "Consecutive doc comments should be on consecutive lines!\nOutput:\n{}",
2296            output
2297        );
2298    }
2299
2300    #[test]
2301    fn test_consecutive_doc_comments_after_block_entry() {
2302        // Bug: after a block entry, consecutive doc comments get a blank line inserted between them
2303        let input = r#"/// Create something
2304CreateThing @insert{
2305    params {name @string}
2306    into things
2307    values {name $name}
2308}
2309
2310/// First line of doc for next entry
2311/// Second line of doc for next entry
2312NextEntry @query{
2313    from things
2314    select {id}
2315}
2316"#;
2317        let output = format(input);
2318        // The two doc comments should stay together without a blank line
2319        assert!(
2320            !output.contains(
2321                "/// First line of doc for next entry\n\n/// Second line of doc for next entry"
2322            ),
2323            "Consecutive doc comments should not have a blank line between them!\nOutput:\n{}",
2324            output
2325        );
2326        // They should be on consecutive lines
2327        assert!(
2328            output.contains(
2329                "/// First line of doc for next entry\n/// Second line of doc for next entry"
2330            ),
2331            "Consecutive doc comments should be on consecutive lines!\nOutput:\n{}",
2332            output
2333        );
2334    }
2335
2336    #[test]
2337    fn test_consecutive_doc_comments_after_line_comment() {
2338        // Bug: after a line comment, consecutive doc comments get a blank line inserted between them
2339        let input = r#"// Section header
2340
2341/// First line of doc
2342/// Second line of doc
2343entry value
2344"#;
2345        let output = format(input);
2346        // The two doc comments should stay together without a blank line
2347        assert!(
2348            !output.contains("/// First line of doc\n\n/// Second line of doc"),
2349            "Consecutive doc comments should not have a blank line between them!\nOutput:\n{}",
2350            output
2351        );
2352    }
2353}