Skip to main content

apollo_federation/connectors/json_selection/
pretty.rs

1//! Pretty printing utility methods
2//!
3//! Working with raw JSONSelections when doing snapshot testing is difficult to
4//! read and makes the snapshots themselves quite large. This module adds a new
5//! pretty printing trait which is then implemented on the various sub types
6//! of the JSONSelection tree.
7
8use itertools::Itertools;
9
10use super::lit_expr::LitExpr;
11use super::lit_expr::LitOp;
12use super::location::WithRange;
13use super::parser::Alias;
14use super::parser::Key;
15use crate::connectors::json_selection::JSONSelection;
16use crate::connectors::json_selection::MethodArgs;
17use crate::connectors::json_selection::NamedSelection;
18use crate::connectors::json_selection::NamingPrefix;
19use crate::connectors::json_selection::PathList;
20use crate::connectors::json_selection::PathSelection;
21use crate::connectors::json_selection::SubSelection;
22use crate::connectors::json_selection::TopLevelSelection;
23
24impl std::fmt::Display for JSONSelection {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.write_str(&PrettyPrintable::pretty_print(self))
27    }
28}
29
30/// Pretty print trait
31///
32/// This trait marks a type as supporting pretty printing itself outside of a
33/// Display implementation, which might be more useful for snapshots.
34///
35/// Output preserves what the user wrote: `PathList::Expr(inner, tail)` always
36/// emits `$(inner)rest`, because a `PathList::Expr` node in the AST can only
37/// come from an explicit `$(...)` wrapper in source (that invariant is
38/// enforced by the parser). There is no spec-dependent rewriting.
39pub(crate) trait PrettyPrintable {
40    /// Pretty print the struct
41    fn pretty_print(&self) -> String {
42        self.pretty_print_with_indentation(false, 0)
43    }
44
45    /// Pretty print the struct, with indentation
46    ///
47    /// Each indentation level is marked with 2 spaces, with `inline` signifying
48    /// that the first line should be not indented.
49    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String;
50}
51
52/// Helper method to generate indentation
53fn indent_chars(indent: usize) -> String {
54    "  ".repeat(indent)
55}
56
57impl PrettyPrintable for JSONSelection {
58    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
59        match &self.inner {
60            TopLevelSelection::Named(named) => named.print_subselections(inline, indentation),
61            TopLevelSelection::Value(lit) => lit.pretty_print_with_indentation(inline, indentation),
62        }
63    }
64}
65
66impl PrettyPrintable for SubSelection {
67    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
68        let mut result = String::new();
69
70        result.push('{');
71
72        if self.selections.is_empty() {
73            result.push('}');
74            return result;
75        }
76
77        if inline {
78            result.push(' ');
79        } else {
80            result.push('\n');
81            result.push_str(indent_chars(indentation + 1).as_str());
82        }
83
84        result.push_str(&self.print_subselections(inline, indentation + 1));
85
86        if inline {
87            result.push(' ');
88        } else {
89            result.push('\n');
90            result.push_str(indent_chars(indentation).as_str());
91        }
92
93        result.push('}');
94
95        result
96    }
97}
98
99impl SubSelection {
100    /// Prints all of the selections in a subselection
101    fn print_subselections(&self, inline: bool, indentation: usize) -> String {
102        let separator = if inline {
103            ' '.to_string()
104        } else {
105            format!("\n{}", indent_chars(indentation))
106        };
107
108        self.selections
109            .iter()
110            .map(|s| s.pretty_print_with_indentation(inline, indentation))
111            .join(separator.as_str())
112    }
113}
114
115impl PrettyPrintable for PathSelection {
116    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
117        let inner = self.path.pretty_print_with_indentation(inline, indentation);
118        // Because we can't tell where PathList::Key elements appear in the path
119        // once we're inside PathList::pretty_print_with_indentation, we print
120        // all PathList::Key elements with a leading '.' character, but we
121        // remove the initial '.' if the path has more than one element, because
122        // then the leading '.' is not necessary to disambiguate the key from a
123        // field. To complicate matters further, inner may begin with spaces due
124        // to indentation.
125        let leading_space_count = inner.chars().take_while(|c| *c == ' ').count();
126        let suffix = inner[leading_space_count..].to_string();
127        if let Some(after_dot) = suffix.strip_prefix('.') {
128            // Strip the '.' but keep any leading spaces.
129            format!("{}{}", " ".repeat(leading_space_count), after_dot)
130        } else {
131            inner
132        }
133    }
134}
135
136impl PrettyPrintable for PathList {
137    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
138        let mut result = String::new();
139
140        match self {
141            Self::Var(var, tail) => {
142                let rest = tail.pretty_print_with_indentation(inline, indentation);
143                result.push_str(var.as_str());
144                result.push_str(rest.as_str());
145            }
146            Self::Key(key, tail) => {
147                result.push('.');
148                result.push_str(key.pretty_print().as_str());
149                let rest = tail.pretty_print_with_indentation(inline, indentation);
150                result.push_str(rest.as_str());
151            }
152            Self::Expr(expr, tail) => {
153                // `PathList::Expr` comes only from an explicit `$(...)` in
154                // source, so pretty-print it back verbatim — the wrapper is
155                // part of what the user wrote, not a syntactic accident.
156                let inner = expr.pretty_print_with_indentation(inline, indentation);
157                let rest = tail.pretty_print_with_indentation(inline, indentation);
158                result.push_str("$(");
159                result.push_str(inner.as_str());
160                result.push(')');
161                result.push_str(rest.as_str());
162            }
163            Self::Method(method, args, tail) => {
164                result.push_str("->");
165                result.push_str(method.as_str());
166                if let Some(args) = args {
167                    result.push_str(
168                        args.pretty_print_with_indentation(inline, indentation)
169                            .as_str(),
170                    );
171                }
172                result.push_str(
173                    tail.pretty_print_with_indentation(inline, indentation)
174                        .as_str(),
175                );
176            }
177            Self::Question(tail) => {
178                result.push('?');
179                let rest = tail.pretty_print_with_indentation(true, indentation);
180                result.push_str(rest.as_str());
181            }
182            Self::Selection(sub) => {
183                let sub = sub.pretty_print_with_indentation(inline, indentation);
184                result.push(' ');
185                result.push_str(sub.as_str());
186            }
187            Self::Empty => {}
188        }
189
190        result
191    }
192}
193
194impl PrettyPrintable for MethodArgs {
195    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
196        let printed_args: Vec<String> = self
197            .args
198            .iter()
199            .map(|arg| arg.pretty_print_with_indentation(inline, indentation + 1))
200            .collect();
201
202        // Check if args would produce multi-line output in non-inline mode,
203        // which determines whether we break across lines (or add spacing in
204        // inline mode to be consistent with collapsed multi-line output).
205        let would_break = if inline {
206            self.args
207                .iter()
208                .any(|arg| arg.pretty_print_with_indentation(false, 0).contains('\n'))
209        } else {
210            printed_args.iter().any(|a| a.contains('\n'))
211        };
212
213        if !inline && would_break {
214            let indent = indent_chars(indentation + 1);
215            let separator = format!(",\n{indent}");
216            let joined = printed_args.iter().map(String::as_str).join(&separator);
217            format!("(\n{indent}{joined}\n{})", indent_chars(indentation))
218        } else if would_break {
219            let joined = printed_args.iter().map(String::as_str).join(", ");
220            format!("( {joined} )")
221        } else {
222            let joined = printed_args.iter().map(String::as_str).join(", ");
223            format!("({joined})")
224        }
225    }
226}
227
228impl LitExpr {
229    fn is_shorthand_property(key: &WithRange<Key>, value: &WithRange<LitExpr>) -> bool {
230        let Key::Field(key_name) = key.as_ref() else {
231            return false;
232        };
233        let LitExpr::Path(PathSelection { path }) = value.as_ref() else {
234            return false;
235        };
236        let PathList::Key(path_key, tail) = path.as_ref() else {
237            return false;
238        };
239        let tail_is_simple = match tail.as_ref() {
240            PathList::Empty => true,
241            // Allow shorthand for optional paths like { a? } which desugar to { a: a? }
242            PathList::Question(inner) => matches!(inner.as_ref(), PathList::Empty),
243            // Allow shorthand for paths with subselections like { a { b c }, d }
244            PathList::Selection(_) => true,
245            _ => false,
246        };
247        tail_is_simple && path_key.as_str() == key_name
248    }
249}
250
251impl PrettyPrintable for LitExpr {
252    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
253        let mut result = String::new();
254
255        match self {
256            Self::String(s) => {
257                let safely_quoted = serde_json_bytes::Value::String(s.clone().into()).to_string();
258                result.push_str(safely_quoted.as_str());
259            }
260            Self::Number(n) => result.push_str(n.to_string().as_str()),
261            Self::Bool(b) => result.push_str(b.to_string().as_str()),
262            Self::Null => result.push_str("null"),
263            Self::Object(sub) => {
264                result.push('{');
265
266                if sub.selections.is_empty() {
267                    result.push('}');
268                    return result;
269                }
270
271                let mut is_first = true;
272                for sel in &sub.selections {
273                    if is_first {
274                        is_first = false;
275                    } else {
276                        result.push(',');
277                    }
278
279                    if inline {
280                        result.push(' ');
281                    } else {
282                        result.push('\n');
283                        result.push_str(indent_chars(indentation + 1).as_str());
284                    }
285
286                    result.push_str(
287                        sel.pretty_print_with_indentation(inline, indentation + 1)
288                            .as_str(),
289                    );
290                }
291
292                if inline {
293                    result.push(' ');
294                } else {
295                    result.push('\n');
296                    result.push_str(indent_chars(indentation).as_str());
297                }
298
299                result.push('}');
300            }
301            Self::LegacyObject(map) => {
302                result.push('{');
303
304                if map.is_empty() {
305                    result.push('}');
306                    return result;
307                }
308
309                let mut is_first = true;
310                for (key, value) in map {
311                    if is_first {
312                        is_first = false;
313                    } else {
314                        result.push(',');
315                    }
316
317                    if inline {
318                        result.push(' ');
319                    } else {
320                        result.push('\n');
321                        result.push_str(indent_chars(indentation + 1).as_str());
322                    }
323
324                    if Self::is_shorthand_property(key, value) {
325                        // Print just the value path, which includes any
326                        // trailing `?` (e.g. `a?` for { a: a? }).
327                        result.push_str(
328                            value
329                                .pretty_print_with_indentation(inline, indentation + 1)
330                                .as_str(),
331                        );
332                    } else {
333                        result.push_str(key.pretty_print().as_str());
334                        result.push_str(": ");
335                        result.push_str(
336                            value
337                                .pretty_print_with_indentation(inline, indentation + 1)
338                                .as_str(),
339                        );
340                    }
341                }
342
343                if inline {
344                    result.push(' ');
345                } else {
346                    result.push('\n');
347                    result.push_str(indent_chars(indentation).as_str());
348                }
349
350                result.push('}');
351            }
352            Self::Array(vec) => {
353                result.push('[');
354                let mut is_first = true;
355                for value in vec {
356                    if is_first {
357                        is_first = false;
358                    } else {
359                        result.push_str(", ");
360                    }
361                    result.push_str(
362                        value
363                            .pretty_print_with_indentation(inline, indentation)
364                            .as_str(),
365                    );
366                }
367                result.push(']');
368            }
369            Self::Path(path) => {
370                result.push_str(
371                    path.pretty_print_with_indentation(inline, indentation)
372                        .as_str(),
373                );
374            }
375            Self::LitPath(literal, subpath) => {
376                result.push_str(
377                    literal
378                        .pretty_print_with_indentation(inline, indentation)
379                        .as_str(),
380                );
381                result.push_str(
382                    subpath
383                        .pretty_print_with_indentation(inline, indentation)
384                        .as_str(),
385                );
386            }
387            Self::OpChain(op, operands) => {
388                let op_str = match op.as_ref() {
389                    LitOp::NullishCoalescing => " ?? ",
390                    LitOp::NoneCoalescing => " ?! ",
391                };
392
393                for (i, operand) in operands.iter().enumerate() {
394                    if i > 0 {
395                        result.push_str(op_str);
396                    }
397                    result.push_str(
398                        operand
399                            .pretty_print_with_indentation(inline, indentation)
400                            .as_str(),
401                    );
402                }
403            }
404        }
405
406        result
407    }
408}
409
410impl PrettyPrintable for NamedSelection {
411    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
412        let mut result = String::new();
413
414        // Collapse `alias: key...` to `key...` when alias matches the path's
415        // single leading key (e.g. `a: a?` prints as `a?`). Both forms are
416        // semantically equivalent and round-trip through the parser.
417        let is_shorthand = if let NamingPrefix::Alias(alias) = &self.prefix
418            && let LitExpr::Path(path) = self.path.as_ref()
419        {
420            path.get_single_key()
421                .is_some_and(|key| alias.name.as_ref() == key.as_ref())
422        } else {
423            false
424        };
425
426        match &self.prefix {
427            NamingPrefix::None => {}
428            NamingPrefix::Alias(alias) => {
429                if !is_shorthand {
430                    result.push_str(alias.pretty_print().as_str());
431                    result.push(' ');
432                }
433            }
434            NamingPrefix::Spread(token_range) => {
435                if token_range.is_some() {
436                    result.push_str("... ");
437                }
438            }
439        };
440
441        // The .trim_start() handles the case when self.path is just a
442        // SubSelection (i.e., a NamedGroupSelection), since that PathList
443        // variant typically prints a single leading space.
444        let pretty_path = self.path.pretty_print_with_indentation(inline, indentation);
445        result.push_str(pretty_path.trim_start());
446
447        result
448    }
449}
450
451impl PrettyPrintable for Alias {
452    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
453        let mut result = String::new();
454
455        let name = self.name.pretty_print_with_indentation(inline, indentation);
456        result.push_str(name.as_str());
457        result.push(':');
458
459        result
460    }
461}
462
463impl PrettyPrintable for Key {
464    fn pretty_print_with_indentation(&self, _inline: bool, _indentation: usize) -> String {
465        match self {
466            Self::Field(name) => name.clone(),
467            Self::Quoted(name) => serde_json_bytes::Value::String(name.as_str().into()).to_string(),
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use crate::connectors::JSONSelection;
475    use crate::connectors::PathSelection;
476    use crate::connectors::SubSelection;
477    use crate::connectors::json_selection::NamedSelection;
478    use crate::connectors::json_selection::PrettyPrintable;
479    use crate::connectors::json_selection::location::new_span;
480    use crate::connectors::json_selection::location::new_span_with_spec;
481    use crate::connectors::json_selection::pretty::indent_chars;
482    use crate::connectors::spec::ConnectSpec;
483    use crate::selection;
484
485    // Test all valid pretty print permutations
486    fn test_permutations(selection: impl PrettyPrintable, expected: &str) {
487        let indentation = 4;
488        let expected_indented = expected
489            .lines()
490            .map(|line| format!("{}{line}", indent_chars(indentation)))
491            .collect::<Vec<_>>()
492            .join("\n");
493        let expected_indented = expected_indented.trim_start();
494
495        let prettified = selection.pretty_print();
496        assert_eq!(
497            prettified, expected,
498            "pretty printing did not match: {prettified} != {expected}"
499        );
500
501        let prettified_inline = selection.pretty_print_with_indentation(true, indentation);
502        let expected_inline = collapse_spaces(expected);
503        assert_eq!(
504            prettified_inline.trim_start(),
505            expected_inline.trim_start(),
506            "pretty printing inline did not match: {prettified_inline} != {}",
507            expected_indented.trim_start()
508        );
509
510        let prettified_indented = selection.pretty_print_with_indentation(false, indentation);
511        assert_eq!(
512            prettified_indented, expected_indented,
513            "pretty printing indented did not match: {prettified_indented} != {expected_indented}"
514        );
515    }
516
517    fn collapse_spaces(s: impl Into<String>) -> String {
518        let pattern = regex::Regex::new(r"\s+").expect("valid regex");
519        pattern.replace_all(s.into().as_str(), " ").to_string()
520    }
521
522    #[test]
523    fn it_prints_a_named_selection() {
524        let selections = [
525            // Field
526            "cool",
527            "cool: beans",
528            "cool: beans {\n  whoa\n}",
529            // Path
530            "cool: one.two.three",
531            // Quoted
532            r#"cool: "b e a n s""#,
533            "cool: \"b e a n s\" {\n  a\n  b\n}",
534            // Group
535            "cool: {\n  a\n  b\n}",
536        ];
537        for selection in selections {
538            let (unmatched, named_selection) = NamedSelection::parse(new_span(selection)).unwrap();
539            assert!(
540                unmatched.is_empty(),
541                "static named selection was not fully parsed: '{selection}' ({named_selection:?}) had unmatched '{unmatched}'"
542            );
543
544            test_permutations(named_selection, selection);
545        }
546    }
547
548    #[test]
549    fn it_prints_a_path_selection() {
550        // These round-trip assertions include `$(...)` wrappers around
551        // literals/OpChains, which v0.2/v0.3 require to disambiguate from
552        // field lookups. v0.4 drops the wrappers (see the v0.4 variant below).
553        let paths = [
554            // Var
555            "$.one.two.three",
556            "$this.a.b",
557            "$this.id.first {\n  username\n}",
558            // Key
559            "$.first",
560            "a.b.c.d.e",
561            "one.two.three {\n  a\n  b\n}",
562            "$.single {\n  x\n}",
563            "results->slice($(-1)->mul($args.suffixLength))",
564            "$(1234)->add($(5678)->mul(2))",
565            "$(true)->and($(false)->not)",
566            "$(12345678987654321)->div(111111111)->eq(111111111)",
567            "$(\"Product\")->slice(0, $(4)->mul(-1))->eq(\"Pro\")",
568            "$($args.unnecessary.parens)->eq(42)",
569        ];
570        for path in paths {
571            let (unmatched, path_selection) =
572                PathSelection::parse(new_span_with_spec(path, ConnectSpec::V0_2)).unwrap();
573            assert!(
574                unmatched.is_empty(),
575                "static path was not fully parsed: '{path}' ({path_selection:?}) had unmatched '{unmatched}'"
576            );
577
578            test_permutations(path_selection, path);
579        }
580    }
581
582    #[test]
583    fn it_prints_a_sub_selection() {
584        let sub = "{\n  a\n  b\n}";
585        let (unmatched, sub_selection) = SubSelection::parse(new_span(sub)).unwrap();
586        assert!(
587            unmatched.is_empty(),
588            "static path was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'"
589        );
590
591        test_permutations(sub_selection, sub);
592    }
593
594    #[test]
595    fn it_prints_an_inline_path_with_subselection() {
596        // This test ensures we do not print a leading ... before some.path,
597        // even though we will probably want to do so once ... and conditional
598        // selections are implemented. The printing of the leading ... was due
599        // to an incomplete removal of experimental support for conditional
600        // selections, which was put on hold to de-risk the GA release.
601        let source = "before\nsome.path {\n  inline\n  me\n}\nafter";
602        let sel = JSONSelection::parse(source).unwrap();
603        test_permutations(sel, source);
604    }
605
606    #[test]
607    fn it_prints_a_nested_sub_selection() {
608        let sub = "{
609          a {
610            b {
611              c
612            }
613          }
614        }";
615        let sub_indented = "{\n  a {\n    b {\n      c\n    }\n  }\n}";
616        let sub_super_indented = "        {\n          a {\n            b {\n              c\n            }\n          }\n        }";
617
618        let (unmatched, sub_selection) = SubSelection::parse(new_span(sub)).unwrap();
619
620        assert!(
621            unmatched.is_empty(),
622            "static nested sub was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'"
623        );
624
625        let pretty = sub_selection.pretty_print();
626        assert_eq!(
627            pretty, sub_indented,
628            "nested sub pretty printing did not match: {pretty} != {sub_indented}"
629        );
630
631        let pretty = sub_selection.pretty_print_with_indentation(false, 4);
632        assert_eq!(
633            pretty,
634            sub_super_indented.trim_start(),
635            "nested inline sub pretty printing did not match: {pretty} != {}",
636            sub_super_indented.trim_start()
637        );
638    }
639
640    #[test]
641    fn it_prints_root_selection() {
642        let root_selection = JSONSelection::parse("id name").unwrap();
643        test_permutations(root_selection, "id\nname");
644    }
645
646    // Round-trip helpers for connect/v0.4 top-level LitExpr forms. The
647    // pretty-printer preserves whatever `$(...)` the user wrote (or didn't),
648    // so for these canonical forms parse+pretty is the identity.
649    fn assert_round_trip_v0_4(input: &str, canonical: &str) {
650        let first = selection!(input, ConnectSpec::V0_4);
651        let printed = first.pretty_print();
652        assert_eq!(
653            printed, canonical,
654            "V0_4 pretty-print of `{input}` gave `{printed}`, expected `{canonical}`",
655        );
656        // Re-parsing the canonical form must yield a structurally identical
657        // selection, or the pretty/parse cycle is asymmetric.
658        let second = selection!(canonical, ConnectSpec::V0_4);
659        let second_printed = second.pretty_print();
660        assert_eq!(
661            printed, second_printed,
662            "re-parse of canonical V0_4 form did not reproduce the same AST",
663        );
664    }
665
666    // The top-level `$(...)` form preserves its wrapper through pretty-print:
667    // `PathList::Expr` is reserved for actual `$(...)` in source, so parsing
668    // then pretty-printing is the identity in every case below.
669
670    #[test]
671    fn top_level_v0_4_number_literal() {
672        assert_round_trip_v0_4("$(1234)", "$(1234)");
673    }
674
675    #[test]
676    fn top_level_v0_4_negative_number_literal() {
677        assert_round_trip_v0_4("$(-1)", "$(-1)");
678    }
679
680    #[test]
681    fn top_level_v0_4_string_literal() {
682        assert_round_trip_v0_4(r#"$("hello")"#, r#"$("hello")"#);
683    }
684
685    #[test]
686    fn top_level_v0_4_boolean_literal() {
687        assert_round_trip_v0_4("$(true)", "$(true)");
688    }
689
690    #[test]
691    fn top_level_v0_4_null_literal() {
692        assert_round_trip_v0_4("$(null)", "$(null)");
693    }
694
695    #[test]
696    fn top_level_v0_4_array_literal() {
697        assert_round_trip_v0_4("$([1, 2, 3])", "$([1, 2, 3])");
698    }
699
700    #[test]
701    fn top_level_v0_4_number_with_method_chain() {
702        assert_round_trip_v0_4("$(1234)->add(1111)", "$(1234)->add(1111)");
703    }
704
705    #[test]
706    fn top_level_v0_4_negative_number_with_method_chain() {
707        assert_round_trip_v0_4("$(-1)->add(10)", "$(-1)->add(10)");
708    }
709
710    #[test]
711    fn top_level_v0_4_string_with_method_chain() {
712        assert_round_trip_v0_4(r#"$("abc")->first"#, r#"$("abc")->first"#);
713    }
714
715    #[test]
716    fn top_level_v0_4_array_with_method_chain() {
717        assert_round_trip_v0_4("$([1, 2, 3])->last", "$([1, 2, 3])->last");
718    }
719
720    #[test]
721    fn top_level_v0_4_object_literal_with_key_access() {
722        // The `$({ a: 1, b: 2 }.b)` form wraps a `LitPath(Object, Key)`
723        // expression. The pretty-printer formats the object multi-line, so
724        // the canonical reflects that.
725        assert_round_trip_v0_4("$({ a: 1, b: 2 }.b)", "$({\n  a: 1,\n  b: 2\n}.b)");
726    }
727
728    #[test]
729    fn top_level_v0_4_operator_chain() {
730        assert_round_trip_v0_4(
731            r#"$($args.maybe ?? "fallback")"#,
732            r#"$($args.maybe ?? "fallback")"#,
733        );
734    }
735
736    #[test]
737    fn top_level_v0_4_nested_expr_path_preserves_wrappers() {
738        // `$($(-1)->add($(10)))` has three `$(...)` layers in source, and the
739        // pretty-printer preserves every one of them — we never synthesize or
740        // elide `$(...)` at any depth.
741        assert_round_trip_v0_4("$($(-1)->add($(10)))", "$($(-1)->add($(10)))");
742    }
743
744    #[test]
745    fn top_level_v0_4_bare_single_key_still_named() {
746        // Single-key ambiguity: a bare key at the top level must still
747        // parse as a NamedSelectionList so `author` means
748        // `{ author: $.author }`, not a flat value lookup.
749        let sel = selection!("author", ConnectSpec::V0_4);
750        assert_eq!(sel.pretty_print(), "author");
751        assert!(
752            sel.next_subselection().is_some(),
753            "top-level `author` should remain a NamedSelectionList wrapping a SubSelection",
754        );
755    }
756
757    #[test]
758    fn it_reprints_shorthand_properties() {
759        let expected = r#"
760upc
761... category->match(
762  ["book", {
763    __typename: "Book",
764    title,
765    author {
766      id
767    }
768  }],
769  ["film", $ {
770    __typename: "Film"
771    title
772    director {
773      id
774    }
775  }],
776  [@, null]
777)"#
778        .trim_start();
779
780        let sel = selection!(&expected, ConnectSpec::V0_4);
781        crate::assert_debug_snapshot!(&sel);
782
783        test_permutations(sel, expected);
784    }
785}