Skip to main content

apollo_federation/connectors/migration/
diff.rs

1//! Structural comparison of two [`JSONSelection`] ASTs, designed for
2//! surveying how the same source text parses differently across
3//! [`ConnectSpec`](crate::connectors::ConnectSpec) versions.
4//!
5//! The main entry point is [`JSONSelection::diff_kinds`], which returns
6//! a [`Vec<DiffKind>`] classifying each structural divergence found
7//! between two parses. The classifier is tuned for the v0.3→v0.4
8//! grammar shift introduced by the `SubSelection`/`LitObject` unification:
9//!
10//! - The **breaking** class is "primitive-token-in-value-position" — a
11//!   bare `true`/`false`/`null` or quoted string that v0.3 parsed as a
12//!   field reference but v0.4 parses as a JSON literal. These show up
13//!   as [`DiffKind::KeyFlippedToLiteralNull`], `Bool`, and `String`.
14//!
15//! - The **cosmetic** class is the unification itself: v0.3's
16//!   `Alias { … }` was parsed as a `PathSelection` whose only path
17//!   element was the trailing `SubSelection`; v0.4 parses the same
18//!   source as `LitExpr::Object(SubSelection)`. Same evaluation
19//!   semantics, different AST shape. These show up as
20//!   [`DiffKind::SubSelectionToLitObject`].
21//!
22//! Anything we can't classify is [`DiffKind::Other`].
23
24use serde::Serialize;
25
26use crate::connectors::json_selection::JSONSelection;
27use crate::connectors::json_selection::Key;
28use crate::connectors::json_selection::LitExpr;
29use crate::connectors::json_selection::NamedSelection;
30use crate::connectors::json_selection::PathList;
31use crate::connectors::json_selection::PathSelection;
32use crate::connectors::json_selection::SubSelection;
33use crate::connectors::json_selection::TopLevelSelection;
34use crate::connectors::json_selection::location::OffsetRange;
35use crate::connectors::json_selection::location::Ranged;
36use crate::connectors::json_selection::location::WithRange;
37
38/// A single structural difference between two [`JSONSelection`] parses
39/// of the same source text under different
40/// [`ConnectSpec`](crate::connectors::ConnectSpec) versions.
41///
42/// The breaking variants (`KeyFlippedTo…`) carry a `followed_by` field
43/// that records whether the v0.4 literal is followed by additional
44/// path access (e.g., `null.foo`, `"x"->method`, `true { … }`). When
45/// `followed_by != FollowedBy::Nothing` the v0.4 form is almost
46/// certainly a parsing accident — literals don't have fields — and
47/// the user's clear intent was the v0.3 field-reference reading. Such
48/// sites can be auto-fixed by prepending `$.` to the original token.
49#[derive(Debug, Clone, Serialize)]
50#[serde(tag = "kind", rename_all = "snake_case")]
51pub enum DiffKind {
52    /// A bare `null` token that v0.3 parsed as the field reference
53    /// `Key::Field("null")` but v0.4 parses as `LitExpr::Null`.
54    KeyFlippedToLiteralNull {
55        source_range: Option<(usize, usize)>,
56        followed_by: FollowedBy,
57    },
58
59    /// A bare `true` or `false` token that v0.3 parsed as the field
60    /// reference `Key::Field("true" | "false")` but v0.4 parses as
61    /// `LitExpr::Bool(value)`.
62    KeyFlippedToLiteralBool {
63        value: bool,
64        source_range: Option<(usize, usize)>,
65        followed_by: FollowedBy,
66    },
67
68    /// A bare identifier-shaped token that v0.3 parsed as a field
69    /// reference but v0.4 parses as a string literal. (This is rare;
70    /// usually `Key::Field` → `LitExpr::Bool/Null` is the case.)
71    KeyFieldFlippedToLiteralString {
72        text: String,
73        source_range: Option<(usize, usize)>,
74        followed_by: FollowedBy,
75    },
76
77    /// A quoted-string token that v0.3 parsed as the field reference
78    /// `Key::Quoted("...")` but v0.4 parses as `LitExpr::String("...")`.
79    KeyQuotedFlippedToLiteralString {
80        text: String,
81        source_range: Option<(usize, usize)>,
82        followed_by: FollowedBy,
83    },
84
85    /// v0.3 parsed `Alias { ... }` as a `PathSelection` whose only
86    /// path element was a trailing `SubSelection`; v0.4 parses the
87    /// same source as `LitExpr::Object(SubSelection)`. Semantically
88    /// equivalent — emitted so the survey can distinguish cosmetic
89    /// unification noise from real breaking changes.
90    SubSelectionToLitObject {
91        source_range: Option<(usize, usize)>,
92    },
93
94    /// v0.3 parsed an object literal as `LegacyObject` (key → value
95    /// map); v0.4 parses it as `LitExpr::Object(SubSelection)`.
96    /// Cosmetic — same evaluation semantics under the unification.
97    LegacyObjectToLitObject {
98        source_range: Option<(usize, usize)>,
99    },
100
101    /// A divergence we don't classify in detail. Carries the AST
102    /// variant names from each parse for triage.
103    Other {
104        v03_variant: &'static str,
105        v04_variant: &'static str,
106        source_range: Option<(usize, usize)>,
107    },
108}
109
110/// What (if anything) follows a v0.4 literal in the `LitPath` tail.
111/// Literals don't currently carry fields/methods of their own, so any
112/// non-`Nothing` value here strongly suggests the user's intent was a
113/// field-reference (the v0.3 reading), and the v0.4 parse is a
114/// silently-accepted accident.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
116#[serde(rename_all = "snake_case")]
117pub enum FollowedBy {
118    /// Nothing — the literal stands alone (e.g., `foo: null`).
119    Nothing,
120    /// Path access via `.key` or `."quoted"`.
121    KeyAccess,
122    /// Method invocation via `->method(...)`.
123    Method,
124    /// A trailing sub-selection block `{ ... }`.
125    SubSelection,
126    /// Optional-chaining `?`.
127    Question,
128    /// A v0.4-`$(...)` expression follow-on (shouldn't normally happen).
129    Expr,
130}
131
132fn classify_followed_by(tail: &WithRange<PathList>) -> FollowedBy {
133    match tail.as_ref() {
134        PathList::Empty => FollowedBy::Nothing,
135        PathList::Key(_, _) => FollowedBy::KeyAccess,
136        PathList::Method(_, _, _) => FollowedBy::Method,
137        PathList::Selection(_) => FollowedBy::SubSelection,
138        PathList::Question(_) => FollowedBy::Question,
139        PathList::Expr(_, _) => FollowedBy::Expr,
140        PathList::Var(_, _) => FollowedBy::KeyAccess,
141    }
142}
143
144impl JSONSelection {
145    /// Walk the inner AST of `self` and `other` in lockstep and return
146    /// a list of classified structural differences. The returned `Vec`
147    /// is empty iff [`structural_eq`](Self::structural_eq) returns
148    /// `true`.
149    ///
150    /// This is intended for surveying how the *same* source text parses
151    /// differently under different `ConnectSpec` versions; the
152    /// comparison is not meaningful when applied to unrelated
153    /// selections.
154    pub fn diff_kinds(&self, other: &Self) -> Vec<DiffKind> {
155        let mut out = Vec::new();
156        diff_top_level(&self.inner, &other.inner, &mut out);
157        out
158    }
159}
160
161fn diff_top_level(v3: &TopLevelSelection, v4: &TopLevelSelection, out: &mut Vec<DiffKind>) {
162    match (v3, v4) {
163        (TopLevelSelection::Named(s3), TopLevelSelection::Named(s4)) => {
164            diff_subselection(s3, s4, out)
165        }
166        (TopLevelSelection::Value(l3), TopLevelSelection::Value(l4)) => diff_litexpr(l3, l4, out),
167        // The Named→Value flip at top level is the divergent case where the
168        // entire selection source is a single primitive token (`null`,
169        // `true`, `"foo"`). v0.3 parses it as a NamedSelectionList containing
170        // one anonymous Key; v0.4 parses it as a bare LitExpr value.
171        (TopLevelSelection::Named(s3), TopLevelSelection::Value(l4)) => {
172            if let Some(diff) = classify_top_level_key_to_literal(s3, l4) {
173                out.push(diff);
174                return;
175            }
176            out.push(DiffKind::Other {
177                v03_variant: "TopLevel::Named",
178                v04_variant: "TopLevel::Value",
179                source_range: range_to_pair(l4.range()),
180            });
181        }
182        (TopLevelSelection::Value(_), TopLevelSelection::Named(_)) => out.push(DiffKind::Other {
183            v03_variant: "TopLevel::Value",
184            v04_variant: "TopLevel::Named",
185            source_range: None,
186        }),
187    }
188}
189
190/// When the top-level shape flips Named→Value, check if v0.3 had exactly
191/// one anonymous NamedSelection wrapping a single primitive Key — that's
192/// the top-level form of `KeyFlippedTo…`.
193fn classify_top_level_key_to_literal(
194    v3: &SubSelection,
195    v4: &WithRange<LitExpr>,
196) -> Option<DiffKind> {
197    let only = match v3.selections.as_slice() {
198        [n] => n,
199        _ => return None,
200    };
201    let path = match only.path.as_ref() {
202        LitExpr::Path(p) => p,
203        _ => return None,
204    };
205    let key = single_key_of_path(path)?;
206    let range = range_to_pair(v4.range());
207    // At top level the v0.3 path collapses to a single Key with no tail.
208    let followed_by = FollowedBy::Nothing;
209    match (key, v4.as_ref()) {
210        (Key::Field(name), LitExpr::Null) if name == "null" => {
211            Some(DiffKind::KeyFlippedToLiteralNull {
212                source_range: range,
213                followed_by,
214            })
215        }
216        (Key::Field(name), LitExpr::Bool(value)) if name == "true" || name == "false" => {
217            Some(DiffKind::KeyFlippedToLiteralBool {
218                value: *value,
219                source_range: range,
220                followed_by,
221            })
222        }
223        (Key::Field(name), LitExpr::String(_)) => Some(DiffKind::KeyFieldFlippedToLiteralString {
224            text: name.clone(),
225            source_range: range,
226            followed_by,
227        }),
228        (Key::Quoted(name), LitExpr::String(_)) => {
229            Some(DiffKind::KeyQuotedFlippedToLiteralString {
230                text: name.clone(),
231                source_range: range,
232                followed_by,
233            })
234        }
235        _ => None,
236    }
237}
238
239fn diff_subselection(v3: &SubSelection, v4: &SubSelection, out: &mut Vec<DiffKind>) {
240    if v3.selections.len() != v4.selections.len() {
241        out.push(DiffKind::Other {
242            v03_variant: "SubSelection",
243            v04_variant: "SubSelection",
244            source_range: range_to_pair(v4.range()),
245        });
246        return;
247    }
248    for (n3, n4) in v3.selections.iter().zip(v4.selections.iter()) {
249        diff_named_selection(n3, n4, out);
250    }
251}
252
253fn diff_named_selection(v3: &NamedSelection, v4: &NamedSelection, out: &mut Vec<DiffKind>) {
254    diff_litexpr(&v3.path, &v4.path, out);
255}
256
257fn diff_litexpr(v3: &WithRange<LitExpr>, v4: &WithRange<LitExpr>, out: &mut Vec<DiffKind>) {
258    let v3_inner = v3.as_ref();
259    let v4_inner = v4.as_ref();
260
261    // Identical -> nothing to record (recursing into Object/Array/etc handles internal differences).
262    if v3_inner == v4_inner {
263        return;
264    }
265
266    // The breaking class: v0.3 parsed a single-key path that v0.4 sees as a primitive.
267    if let LitExpr::Path(path) = v3_inner
268        && let Some(key) = single_key_of_path(path)
269    {
270        match (key, v4_inner) {
271            (Key::Field(name), LitExpr::Null) if name == "null" => {
272                out.push(DiffKind::KeyFlippedToLiteralNull {
273                    source_range: range_to_pair(v4.range()),
274                    followed_by: FollowedBy::Nothing,
275                });
276                return;
277            }
278            (Key::Field(name), LitExpr::Bool(value)) if name == "true" || name == "false" => {
279                out.push(DiffKind::KeyFlippedToLiteralBool {
280                    value: *value,
281                    source_range: range_to_pair(v4.range()),
282                    followed_by: FollowedBy::Nothing,
283                });
284                return;
285            }
286            (Key::Field(name), LitExpr::String(s)) => {
287                out.push(DiffKind::KeyFieldFlippedToLiteralString {
288                    text: name.clone(),
289                    source_range: range_to_pair(v4.range()),
290                    followed_by: FollowedBy::Nothing,
291                });
292                let _ = s;
293                return;
294            }
295            (Key::Quoted(name), LitExpr::String(_)) => {
296                out.push(DiffKind::KeyQuotedFlippedToLiteralString {
297                    text: name.clone(),
298                    source_range: range_to_pair(v4.range()),
299                    followed_by: FollowedBy::Nothing,
300                });
301                return;
302            }
303            _ => {}
304        }
305    }
306
307    // The cosmetic class: v0.3 parsed `Alias { ... }` as Path-with-only-Selection;
308    // v0.4 parses the same source as LitExpr::Object(SubSelection).
309    if let (LitExpr::Path(path), LitExpr::Object(obj4)) = (v3_inner, v4_inner)
310        && let Some(sub3) = path_starts_with_subselection_only(path)
311    {
312        out.push(DiffKind::SubSelectionToLitObject {
313            source_range: range_to_pair(v4.range()),
314        });
315        diff_subselection(sub3, obj4, out);
316        return;
317    }
318
319    // Another cosmetic case: v0.3 used `LitExpr::LegacyObject` (key→value
320    // map) for object literals; v0.4 unifies that with `LitExpr::Object`.
321    if let (LitExpr::LegacyObject(_), LitExpr::Object(_)) = (v3_inner, v4_inner) {
322        out.push(DiffKind::LegacyObjectToLitObject {
323            source_range: range_to_pair(v4.range()),
324        });
325        return;
326    }
327
328    // Breaking class extended: v0.3 has a path that *starts with* a
329    // primitive-shaped Key followed by more path elements; v0.4 parses the
330    // primitive as a literal, with the rest of the path as `LitPath`.
331    if let (LitExpr::Path(path), LitExpr::LitPath(root, tail4)) = (v3_inner, v4_inner)
332        && let PathList::Key(key, rest3) = path.path.as_ref()
333    {
334        let range = range_to_pair(v4.range());
335        let followed_by = classify_followed_by(tail4);
336        let mut emitted = None;
337        match (key.as_ref(), root.as_ref()) {
338            (Key::Field(name), LitExpr::Null) if name == "null" => {
339                emitted = Some(DiffKind::KeyFlippedToLiteralNull {
340                    source_range: range,
341                    followed_by,
342                });
343            }
344            (Key::Field(name), LitExpr::Bool(value)) if name == "true" || name == "false" => {
345                emitted = Some(DiffKind::KeyFlippedToLiteralBool {
346                    value: *value,
347                    source_range: range,
348                    followed_by,
349                });
350            }
351            (Key::Field(name), LitExpr::String(_)) => {
352                emitted = Some(DiffKind::KeyFieldFlippedToLiteralString {
353                    text: name.clone(),
354                    source_range: range,
355                    followed_by,
356                });
357            }
358            (Key::Quoted(name), LitExpr::String(_)) => {
359                emitted = Some(DiffKind::KeyQuotedFlippedToLiteralString {
360                    text: name.clone(),
361                    source_range: range,
362                    followed_by,
363                });
364            }
365            _ => {}
366        }
367        if let Some(diff) = emitted {
368            out.push(diff);
369            diff_pathlist(rest3, tail4, out, v4.range());
370            return;
371        }
372    }
373
374    // Same variant on both sides — recurse into children.
375    match (v3_inner, v4_inner) {
376        (LitExpr::Object(a), LitExpr::Object(b)) => diff_subselection(a, b, out),
377        (LitExpr::Array(a), LitExpr::Array(b)) => {
378            if a.len() != b.len() {
379                out.push(DiffKind::Other {
380                    v03_variant: "LitExpr::Array",
381                    v04_variant: "LitExpr::Array",
382                    source_range: range_to_pair(v4.range()),
383                });
384            } else {
385                for (x, y) in a.iter().zip(b.iter()) {
386                    diff_litexpr(x, y, out);
387                }
388            }
389        }
390        (LitExpr::Path(a), LitExpr::Path(b)) => diff_pathselection(a, b, out, v4.range()),
391        // Catch-all for shape mismatches we don't classify in detail.
392        _ => out.push(DiffKind::Other {
393            v03_variant: lit_variant_name(v3_inner),
394            v04_variant: lit_variant_name(v4_inner),
395            source_range: range_to_pair(v4.range()),
396        }),
397    }
398}
399
400fn diff_pathselection(
401    v3: &PathSelection,
402    v4: &PathSelection,
403    out: &mut Vec<DiffKind>,
404    range: OffsetRange,
405) {
406    diff_pathlist(&v3.path, &v4.path, out, range);
407}
408
409fn diff_pathlist(
410    v3: &WithRange<PathList>,
411    v4: &WithRange<PathList>,
412    out: &mut Vec<DiffKind>,
413    fallback_range: OffsetRange,
414) {
415    if v3.as_ref() == v4.as_ref() {
416        return;
417    }
418    match (v3.as_ref(), v4.as_ref()) {
419        (PathList::Key(_, tail3), PathList::Key(_, tail4)) => {
420            diff_pathlist(tail3, tail4, out, fallback_range);
421        }
422        (PathList::Var(_, tail3), PathList::Var(_, tail4)) => {
423            diff_pathlist(tail3, tail4, out, fallback_range);
424        }
425        (PathList::Method(_, args3, tail3), PathList::Method(_, args4, tail4)) => {
426            match (args3, args4) {
427                (Some(a3), Some(a4)) if a3.args.len() == a4.args.len() => {
428                    for (x, y) in a3.args.iter().zip(a4.args.iter()) {
429                        diff_litexpr(x, y, out);
430                    }
431                }
432                (None, None) => {}
433                _ => out.push(DiffKind::Other {
434                    v03_variant: "MethodArgs",
435                    v04_variant: "MethodArgs",
436                    source_range: range_to_pair(fallback_range.clone()),
437                }),
438            }
439            diff_pathlist(tail3, tail4, out, fallback_range);
440        }
441        (PathList::Expr(e3, tail3), PathList::Expr(e4, tail4)) => {
442            diff_litexpr(e3, e4, out);
443            diff_pathlist(tail3, tail4, out, fallback_range);
444        }
445        (PathList::Question(tail3), PathList::Question(tail4)) => {
446            diff_pathlist(tail3, tail4, out, fallback_range);
447        }
448        (PathList::Selection(a), PathList::Selection(b)) => diff_subselection(a, b, out),
449        _ => out.push(DiffKind::Other {
450            v03_variant: pathlist_variant_name(v3.as_ref()),
451            v04_variant: pathlist_variant_name(v4.as_ref()),
452            source_range: range_to_pair(v4.range().or(fallback_range)),
453        }),
454    }
455}
456
457fn single_key_of_path(path: &PathSelection) -> Option<&Key> {
458    if let PathList::Key(key, tail) = path.path.as_ref()
459        && matches!(tail.as_ref(), PathList::Empty)
460    {
461        return Some(key.as_ref());
462    }
463    None
464}
465
466fn path_starts_with_subselection_only(path: &PathSelection) -> Option<&SubSelection> {
467    if let PathList::Selection(sub) = path.path.as_ref() {
468        return Some(sub);
469    }
470    None
471}
472
473fn lit_variant_name(l: &LitExpr) -> &'static str {
474    match l {
475        LitExpr::String(_) => "LitExpr::String",
476        LitExpr::Number(_) => "LitExpr::Number",
477        LitExpr::Bool(_) => "LitExpr::Bool",
478        LitExpr::Null => "LitExpr::Null",
479        LitExpr::LegacyObject(_) => "LitExpr::LegacyObject",
480        LitExpr::Object(_) => "LitExpr::Object",
481        LitExpr::Array(_) => "LitExpr::Array",
482        LitExpr::Path(_) => "LitExpr::Path",
483        LitExpr::LitPath(_, _) => "LitExpr::LitPath",
484        LitExpr::OpChain(_, _) => "LitExpr::OpChain",
485    }
486}
487
488fn pathlist_variant_name(p: &PathList) -> &'static str {
489    match p {
490        PathList::Var(_, _) => "PathList::Var",
491        PathList::Key(_, _) => "PathList::Key",
492        PathList::Expr(_, _) => "PathList::Expr",
493        PathList::Method(_, _, _) => "PathList::Method",
494        PathList::Question(_) => "PathList::Question",
495        PathList::Selection(_) => "PathList::Selection",
496        PathList::Empty => "PathList::Empty",
497    }
498}
499
500fn range_to_pair(r: OffsetRange) -> Option<(usize, usize)> {
501    r.map(|range| (range.start, range.end))
502}