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