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(&self.pretty_print())
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.
34pub(crate) trait PrettyPrintable {
35    /// Pretty print the struct
36    fn pretty_print(&self) -> String {
37        self.pretty_print_with_indentation(false, 0)
38    }
39
40    /// Pretty print the struct, with indentation
41    ///
42    /// Each indentation level is marked with 2 spaces, with `inline` signifying
43    /// that the first line should be not indented.
44    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String;
45}
46
47/// Helper method to generate indentation
48fn indent_chars(indent: usize) -> String {
49    "  ".repeat(indent)
50}
51
52impl PrettyPrintable for JSONSelection {
53    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
54        match &self.inner {
55            TopLevelSelection::Named(named) => named.print_subselections(inline, indentation),
56            TopLevelSelection::Path(path) => {
57                path.pretty_print_with_indentation(inline, indentation)
58            }
59        }
60    }
61}
62
63impl PrettyPrintable for SubSelection {
64    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
65        let mut result = String::new();
66
67        result.push('{');
68
69        if self.selections.is_empty() {
70            result.push('}');
71            return result;
72        }
73
74        if inline {
75            result.push(' ');
76        } else {
77            result.push('\n');
78            result.push_str(indent_chars(indentation + 1).as_str());
79        }
80
81        result.push_str(&self.print_subselections(inline, indentation + 1));
82
83        if inline {
84            result.push(' ');
85        } else {
86            result.push('\n');
87            result.push_str(indent_chars(indentation).as_str());
88        }
89
90        result.push('}');
91
92        result
93    }
94}
95
96impl SubSelection {
97    /// Prints all of the selections in a subselection
98    fn print_subselections(&self, inline: bool, indentation: usize) -> String {
99        let separator = if inline {
100            ' '.to_string()
101        } else {
102            format!("\n{}", indent_chars(indentation))
103        };
104
105        self.selections
106            .iter()
107            .map(|s| s.pretty_print_with_indentation(inline, indentation))
108            .join(separator.as_str())
109    }
110}
111
112impl PrettyPrintable for PathSelection {
113    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
114        let inner = self.path.pretty_print_with_indentation(inline, indentation);
115        // Because we can't tell where PathList::Key elements appear in the path
116        // once we're inside PathList::pretty_print_with_indentation, we print
117        // all PathList::Key elements with a leading '.' character, but we
118        // remove the initial '.' if the path has more than one element, because
119        // then the leading '.' is not necessary to disambiguate the key from a
120        // field. To complicate matters further, inner may begin with spaces due
121        // to indentation.
122        let leading_space_count = inner.chars().take_while(|c| *c == ' ').count();
123        let suffix = inner[leading_space_count..].to_string();
124        if let Some(after_dot) = suffix.strip_prefix('.') {
125            // Strip the '.' but keep any leading spaces.
126            format!("{}{}", " ".repeat(leading_space_count), after_dot)
127        } else {
128            inner
129        }
130    }
131}
132
133impl PrettyPrintable for PathList {
134    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
135        let mut result = String::new();
136
137        match self {
138            Self::Var(var, tail) => {
139                let rest = tail.pretty_print_with_indentation(inline, indentation);
140                result.push_str(var.as_str());
141                result.push_str(rest.as_str());
142            }
143            Self::Key(key, tail) => {
144                result.push('.');
145                result.push_str(key.pretty_print().as_str());
146                let rest = tail.pretty_print_with_indentation(inline, indentation);
147                result.push_str(rest.as_str());
148            }
149            Self::Expr(expr, tail) => {
150                let rest = tail.pretty_print_with_indentation(inline, indentation);
151                result.push_str("$(");
152                result.push_str(
153                    expr.pretty_print_with_indentation(inline, indentation)
154                        .as_str(),
155                );
156                result.push(')');
157                result.push_str(rest.as_str());
158            }
159            Self::Method(method, args, tail) => {
160                result.push_str("->");
161                result.push_str(method.as_str());
162                if let Some(args) = args {
163                    result.push_str(
164                        args.pretty_print_with_indentation(inline, indentation)
165                            .as_str(),
166                    );
167                }
168                result.push_str(
169                    tail.pretty_print_with_indentation(inline, indentation)
170                        .as_str(),
171                );
172            }
173            Self::Question(tail) => {
174                result.push('?');
175                let rest = tail.pretty_print_with_indentation(true, indentation);
176                result.push_str(rest.as_str());
177            }
178            Self::Selection(sub) => {
179                let sub = sub.pretty_print_with_indentation(inline, indentation);
180                result.push(' ');
181                result.push_str(sub.as_str());
182            }
183            Self::Empty => {}
184        }
185
186        result
187    }
188}
189
190impl PrettyPrintable for MethodArgs {
191    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
192        let printed_args: Vec<String> = self
193            .args
194            .iter()
195            .map(|arg| arg.pretty_print_with_indentation(inline, indentation + 1))
196            .collect();
197
198        // Check if args would produce multi-line output in non-inline mode,
199        // which determines whether we break across lines (or add spacing in
200        // inline mode to be consistent with collapsed multi-line output).
201        let would_break = if inline {
202            self.args
203                .iter()
204                .any(|arg| arg.pretty_print_with_indentation(false, 0).contains('\n'))
205        } else {
206            printed_args.iter().any(|a| a.contains('\n'))
207        };
208
209        if !inline && would_break {
210            let indent = indent_chars(indentation + 1);
211            let separator = format!(",\n{indent}");
212            let joined = printed_args.iter().map(String::as_str).join(&separator);
213            format!("(\n{indent}{joined}\n{})", indent_chars(indentation))
214        } else if would_break {
215            let joined = printed_args.iter().map(String::as_str).join(", ");
216            format!("( {joined} )")
217        } else {
218            let joined = printed_args.iter().map(String::as_str).join(", ");
219            format!("({joined})")
220        }
221    }
222}
223
224impl LitExpr {
225    fn is_shorthand_property(key: &WithRange<Key>, value: &WithRange<LitExpr>) -> bool {
226        let Key::Field(key_name) = key.as_ref() else {
227            return false;
228        };
229        let LitExpr::Path(PathSelection { path }) = value.as_ref() else {
230            return false;
231        };
232        let PathList::Key(path_key, tail) = path.as_ref() else {
233            return false;
234        };
235        let tail_is_simple = match tail.as_ref() {
236            PathList::Empty => true,
237            // Allow shorthand for optional paths like { a? } which desugar to { a: a? }
238            PathList::Question(inner) => matches!(inner.as_ref(), PathList::Empty),
239            // Allow shorthand for paths with subselections like { a { b c }, d }
240            PathList::Selection(_) => true,
241            _ => false,
242        };
243        tail_is_simple && path_key.as_str() == key_name
244    }
245}
246
247impl PrettyPrintable for LitExpr {
248    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
249        let mut result = String::new();
250
251        match self {
252            Self::String(s) => {
253                let safely_quoted = serde_json_bytes::Value::String(s.clone().into()).to_string();
254                result.push_str(safely_quoted.as_str());
255            }
256            Self::Number(n) => result.push_str(n.to_string().as_str()),
257            Self::Bool(b) => result.push_str(b.to_string().as_str()),
258            Self::Null => result.push_str("null"),
259            Self::Object(map) => {
260                result.push('{');
261
262                if map.is_empty() {
263                    result.push('}');
264                    return result;
265                }
266
267                let mut is_first = true;
268                for (key, value) in map {
269                    if is_first {
270                        is_first = false;
271                    } else {
272                        result.push(',');
273                    }
274
275                    if inline {
276                        result.push(' ');
277                    } else {
278                        result.push('\n');
279                        result.push_str(indent_chars(indentation + 1).as_str());
280                    }
281
282                    if Self::is_shorthand_property(key, value) {
283                        // Print just the value path, which includes any
284                        // trailing `?` (e.g. `a?` for { a: a? }).
285                        result.push_str(
286                            value
287                                .pretty_print_with_indentation(inline, indentation + 1)
288                                .as_str(),
289                        );
290                    } else {
291                        result.push_str(key.pretty_print().as_str());
292                        result.push_str(": ");
293                        result.push_str(
294                            value
295                                .pretty_print_with_indentation(inline, indentation + 1)
296                                .as_str(),
297                        );
298                    }
299                }
300
301                if inline {
302                    result.push(' ');
303                } else {
304                    result.push('\n');
305                    result.push_str(indent_chars(indentation).as_str());
306                }
307
308                result.push('}');
309            }
310            Self::Array(vec) => {
311                result.push('[');
312                let mut is_first = true;
313                for value in vec {
314                    if is_first {
315                        is_first = false;
316                    } else {
317                        result.push_str(", ");
318                    }
319                    result.push_str(
320                        value
321                            .pretty_print_with_indentation(inline, indentation)
322                            .as_str(),
323                    );
324                }
325                result.push(']');
326            }
327            Self::Path(path) => {
328                result.push_str(
329                    path.pretty_print_with_indentation(inline, indentation)
330                        .as_str(),
331                );
332            }
333            Self::LitPath(literal, subpath) => {
334                result.push_str(
335                    literal
336                        .pretty_print_with_indentation(inline, indentation)
337                        .as_str(),
338                );
339                result.push_str(
340                    subpath
341                        .pretty_print_with_indentation(inline, indentation)
342                        .as_str(),
343                );
344            }
345            Self::OpChain(op, operands) => {
346                let op_str = match op.as_ref() {
347                    LitOp::NullishCoalescing => " ?? ",
348                    LitOp::NoneCoalescing => " ?! ",
349                };
350
351                for (i, operand) in operands.iter().enumerate() {
352                    if i > 0 {
353                        result.push_str(op_str);
354                    }
355                    result.push_str(
356                        operand
357                            .pretty_print_with_indentation(inline, indentation)
358                            .as_str(),
359                    );
360                }
361            }
362        }
363
364        result
365    }
366}
367
368impl PrettyPrintable for NamedSelection {
369    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
370        let mut result = String::new();
371
372        match &self.prefix {
373            NamingPrefix::None => {}
374            NamingPrefix::Alias(alias) => {
375                result.push_str(alias.pretty_print().as_str());
376                result.push(' ');
377            }
378            NamingPrefix::Spread(token_range) => {
379                if token_range.is_some() {
380                    result.push_str("... ");
381                }
382            }
383        };
384
385        // The .trim_start() handles the case when self.path is just a
386        // SubSelection (i.e., a NamedGroupSelection), since that PathList
387        // variant typically prints a single leading space.
388        let pretty_path = self.path.pretty_print_with_indentation(inline, indentation);
389        result.push_str(pretty_path.trim_start());
390
391        result
392    }
393}
394
395impl PrettyPrintable for Alias {
396    fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String {
397        let mut result = String::new();
398
399        let name = self.name.pretty_print_with_indentation(inline, indentation);
400        result.push_str(name.as_str());
401        result.push(':');
402
403        result
404    }
405}
406
407impl PrettyPrintable for Key {
408    fn pretty_print_with_indentation(&self, _inline: bool, _indentation: usize) -> String {
409        match self {
410            Self::Field(name) => name.clone(),
411            Self::Quoted(name) => serde_json_bytes::Value::String(name.as_str().into()).to_string(),
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use crate::connectors::JSONSelection;
419    use crate::connectors::PathSelection;
420    use crate::connectors::SubSelection;
421    use crate::connectors::json_selection::NamedSelection;
422    use crate::connectors::json_selection::PrettyPrintable;
423    use crate::connectors::json_selection::location::new_span;
424    use crate::connectors::json_selection::pretty::indent_chars;
425    use crate::connectors::spec::ConnectSpec;
426    use crate::selection;
427
428    // Test all valid pretty print permutations
429    fn test_permutations(selection: impl PrettyPrintable, expected: &str) {
430        let indentation = 4;
431        let expected_indented = expected
432            .lines()
433            .map(|line| format!("{}{line}", indent_chars(indentation)))
434            .collect::<Vec<_>>()
435            .join("\n");
436        let expected_indented = expected_indented.trim_start();
437
438        let prettified = selection.pretty_print();
439        assert_eq!(
440            prettified, expected,
441            "pretty printing did not match: {prettified} != {expected}"
442        );
443
444        let prettified_inline = selection.pretty_print_with_indentation(true, indentation);
445        let expected_inline = collapse_spaces(expected);
446        assert_eq!(
447            prettified_inline.trim_start(),
448            expected_inline.trim_start(),
449            "pretty printing inline did not match: {prettified_inline} != {}",
450            expected_indented.trim_start()
451        );
452
453        let prettified_indented = selection.pretty_print_with_indentation(false, indentation);
454        assert_eq!(
455            prettified_indented, expected_indented,
456            "pretty printing indented did not match: {prettified_indented} != {expected_indented}"
457        );
458    }
459
460    fn collapse_spaces(s: impl Into<String>) -> String {
461        let pattern = regex::Regex::new(r"\s+").expect("valid regex");
462        pattern.replace_all(s.into().as_str(), " ").to_string()
463    }
464
465    #[test]
466    fn it_prints_a_named_selection() {
467        let selections = [
468            // Field
469            "cool",
470            "cool: beans",
471            "cool: beans {\n  whoa\n}",
472            // Path
473            "cool: one.two.three",
474            // Quoted
475            r#"cool: "b e a n s""#,
476            "cool: \"b e a n s\" {\n  a\n  b\n}",
477            // Group
478            "cool: {\n  a\n  b\n}",
479        ];
480        for selection in selections {
481            let (unmatched, named_selection) = NamedSelection::parse(new_span(selection)).unwrap();
482            assert!(
483                unmatched.is_empty(),
484                "static named selection was not fully parsed: '{selection}' ({named_selection:?}) had unmatched '{unmatched}'"
485            );
486
487            test_permutations(named_selection, selection);
488        }
489    }
490
491    #[test]
492    fn it_prints_a_path_selection() {
493        let paths = [
494            // Var
495            "$.one.two.three",
496            "$this.a.b",
497            "$this.id.first {\n  username\n}",
498            // Key
499            "$.first",
500            "a.b.c.d.e",
501            "one.two.three {\n  a\n  b\n}",
502            "$.single {\n  x\n}",
503            "results->slice($(-1)->mul($args.suffixLength))",
504            "$(1234)->add($(5678)->mul(2))",
505            "$(true)->and($(false)->not)",
506            "$(12345678987654321)->div(111111111)->eq(111111111)",
507            "$(\"Product\")->slice(0, $(4)->mul(-1))->eq(\"Pro\")",
508            "$($args.unnecessary.parens)->eq(42)",
509        ];
510        for path in paths {
511            let (unmatched, path_selection) = PathSelection::parse(new_span(path)).unwrap();
512            assert!(
513                unmatched.is_empty(),
514                "static path was not fully parsed: '{path}' ({path_selection:?}) had unmatched '{unmatched}'"
515            );
516
517            test_permutations(path_selection, path);
518        }
519    }
520
521    #[test]
522    fn it_prints_a_sub_selection() {
523        let sub = "{\n  a\n  b\n}";
524        let (unmatched, sub_selection) = SubSelection::parse(new_span(sub)).unwrap();
525        assert!(
526            unmatched.is_empty(),
527            "static path was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'"
528        );
529
530        test_permutations(sub_selection, sub);
531    }
532
533    #[test]
534    fn it_prints_an_inline_path_with_subselection() {
535        // This test ensures we do not print a leading ... before some.path,
536        // even though we will probably want to do so once ... and conditional
537        // selections are implemented. The printing of the leading ... was due
538        // to an incomplete removal of experimental support for conditional
539        // selections, which was put on hold to de-risk the GA release.
540        let source = "before\nsome.path {\n  inline\n  me\n}\nafter";
541        let sel = JSONSelection::parse(source).unwrap();
542        test_permutations(sel, source);
543    }
544
545    #[test]
546    fn it_prints_a_nested_sub_selection() {
547        let sub = "{
548          a {
549            b {
550              c
551            }
552          }
553        }";
554        let sub_indented = "{\n  a {\n    b {\n      c\n    }\n  }\n}";
555        let sub_super_indented = "        {\n          a {\n            b {\n              c\n            }\n          }\n        }";
556
557        let (unmatched, sub_selection) = SubSelection::parse(new_span(sub)).unwrap();
558
559        assert!(
560            unmatched.is_empty(),
561            "static nested sub was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'"
562        );
563
564        let pretty = sub_selection.pretty_print();
565        assert_eq!(
566            pretty, sub_indented,
567            "nested sub pretty printing did not match: {pretty} != {sub_indented}"
568        );
569
570        let pretty = sub_selection.pretty_print_with_indentation(false, 4);
571        assert_eq!(
572            pretty,
573            sub_super_indented.trim_start(),
574            "nested inline sub pretty printing did not match: {pretty} != {}",
575            sub_super_indented.trim_start()
576        );
577    }
578
579    #[test]
580    fn it_prints_root_selection() {
581        let root_selection = JSONSelection::parse("id name").unwrap();
582        test_permutations(root_selection, "id\nname");
583    }
584
585    #[test]
586    fn it_reprints_shorthand_properties() {
587        let expected = r#"
588upc
589... category->match(
590  ["book", {
591    __typename: "Book",
592    title,
593    author {
594      id
595    }
596  }],
597  ["film", $ {
598    __typename: $("Film")
599    title
600    director {
601      id
602    }
603  }],
604  [@, null]
605)"#
606        .trim_start();
607
608        let sel = selection!(&expected, ConnectSpec::V0_4);
609        crate::assert_debug_snapshot!(&sel);
610
611        test_permutations(sel, expected);
612    }
613}