Skip to main content

gqls/
resolve.rs

1//! Field → resolver jump.
2//!
3//! gqls owns the schema; `rq` owns the code. Given a schema field (or type),
4//! we encode graphql-ruby's naming conventions as a prioritized list of `rq`
5//! queries, run `rq --json` for each, and merge the hits — so "where does
6//! `Query.user` resolve?" lands on `Resolvers::User` / `def user` in the server
7//! codebase. Naming varies across apps, so we try several conventions and rank
8//! by (convention priority, then rq's own confidence) rather than guess one.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use anyhow::{anyhow, bail, Result};
15use serde::{Deserialize, Serialize};
16
17use crate::model::{Kind, SchemaRecord};
18
19/// One code definition rq found, plus the candidate query that surfaced it.
20#[derive(Debug, Deserialize, Serialize)]
21pub struct RqHit {
22    pub name: String,
23    pub file: String,
24    pub line: u64,
25    #[serde(default)]
26    pub kind: String,
27    #[serde(default)]
28    pub confidence: f64,
29    /// The enclosing module/class rq reports, used to check that a hit really
30    /// sits where the candidate said it would.
31    #[serde(default)]
32    pub parent: Option<String>,
33    /// The graphql-ruby candidate query that surfaced this hit — set by gqls,
34    /// not read from rq, but included in serialized output.
35    #[serde(default, skip_deserializing)]
36    pub via: String,
37    /// Shared leading path components between the schema's package and this
38    /// hit's file — the primary rank key when a file schema is known, so a
39    /// resolver in the schema's own subgraph outranks a same-named one
40    /// elsewhere in the monorepo.
41    #[serde(default, skip_deserializing)]
42    pub proximity: usize,
43    /// Whether the candidate that found this was a bare name search rather
44    /// than a graphql-ruby convention — a guess, and labelled as one.
45    #[serde(default, skip_deserializing)]
46    pub loose: bool,
47    /// Index of the candidate query that surfaced this hit (0 = best).
48    #[serde(skip)]
49    candidate_rank: usize,
50}
51
52/// Resolve `rec` to its code definition(s) via rq, best first. `schema_path`,
53/// when a local file, ranks hits by package proximity to the schema — the
54/// resolver in the schema's own subgraph wins over a same-named one elsewhere.
55pub fn resolve(
56    rec: &SchemaRecord,
57    code_dir: Option<&str>,
58    schema_path: Option<&Path>,
59    limit: usize,
60) -> Result<Vec<RqHit>> {
61    // Collect every candidate's hits first, then rank as a whole — proximity
62    // can only reorder across candidates once we have them all.
63    //
64    // The same location often turns up under several candidates. Keep the best
65    // account of it rather than the first: a fuzzy `Resolvers::User` may stumble
66    // onto the very definition that `Query#user` then names exactly, and
67    // recording it as a guess because the weaker query got there first would
68    // bury the right answer.
69    let mut best: HashMap<String, RqHit> = HashMap::new();
70    for (idx, cand) in candidates(rec).into_iter().enumerate() {
71        let found = run_rq(&cand.query, code_dir)?;
72        crate::detail!("rq candidate {:?} -> {} hit(s)", cand.query, found.len());
73        for mut hit in found {
74            hit.via = cand.query.clone();
75            hit.candidate_rank = idx;
76            // A convention only counts if the hit actually sits where the
77            // convention said. Otherwise it's a name match wearing a
78            // convention's name.
79            hit.loose = cand.loose || !satisfies(&cand.query, &hit);
80            let key = format!("{}:{}", hit.file, hit.line);
81            match best.get(&key) {
82                Some(prev) if (prev.loose, prev.candidate_rank) <= (hit.loose, idx) => {}
83                _ => {
84                    best.insert(key, hit);
85                }
86            }
87        }
88    }
89    let mut hits: Vec<RqHit> = best.into_values().collect();
90
91    // Package proximity: shared leading path components between the schema's
92    // directory and each hit's file. rq paths are repo-root-relative, so join
93    // them onto the code repo's git toplevel to compare absolute paths.
94    if let Some(schema_dir) = schema_path
95        .and_then(|p| std::fs::canonicalize(p).ok())
96        .and_then(|p| p.parent().map(Path::to_path_buf))
97    {
98        if let Some(root) = git_toplevel(code_dir).and_then(|r| std::fs::canonicalize(r).ok()) {
99            for h in &mut hits {
100                let file_abs = root.join(&h.file);
101                let hit_dir = file_abs.parent().unwrap_or(&root);
102                h.proximity = shared_prefix(&schema_dir, hit_dir);
103            }
104        }
105    }
106
107    // Which convention matched comes first: a hit from `Mutations::VerbNoun`
108    // is evidence, while proximity is a tiebreak between equally-good matches.
109    // Ranking on proximity first let five unrelated files that happen to sit
110    // one directory closer bury a confident match.
111    hits.sort_by(|a, b| {
112        a.loose
113            .cmp(&b.loose)
114            .then(a.candidate_rank.cmp(&b.candidate_rank))
115            .then(b.proximity.cmp(&a.proximity))
116            .then(b.confidence.total_cmp(&a.confidence))
117            // a total order, so equally-ranked hits don't shuffle between runs
118            .then_with(|| (&a.file, a.line).cmp(&(&b.file, b.line)))
119    });
120    hits.truncate(limit);
121    Ok(hits)
122}
123
124/// Whether a hit actually satisfies the qualification the candidate asked for.
125///
126/// rq is a fuzzy navigator by design: a query for `Resolvers::User` will
127/// cheerfully return a bare `class User` in `app/models`, because the name
128/// matches even though the namespace doesn't. That's a fine search result and a
129/// terrible resolver answer — the whole point of asking for `Resolvers::User`
130/// was to test a convention, and a hit outside `Resolvers` didn't pass it. A
131/// bare candidate (`user`) qualifies nothing, so there's nothing to check.
132fn satisfies(candidate: &str, hit: &RqHit) -> bool {
133    let qualifier = match candidate.rsplit_once('#') {
134        Some((q, _)) => q,
135        None => match candidate.rsplit_once("::") {
136            Some((q, _)) => q,
137            None => return true,
138        },
139    };
140    let parent = hit.parent.as_deref().unwrap_or_default();
141    // Every segment asked for has to appear in the hit's own path — `Query`
142    // matches `SomeSubgraph::Schema::Root::Query`, `Resolvers` matches nothing
143    // at the top level of app/models.
144    qualifier
145        .split("::")
146        .all(|want| parent.split("::").any(|have| have == want))
147}
148
149/// The git repo root containing `dir` (or gqls's cwd) — rq's file paths are
150/// relative to it.
151fn git_toplevel(dir: Option<&str>) -> Option<PathBuf> {
152    let mut cmd = Command::new("git");
153    cmd.args(["rev-parse", "--show-toplevel"]);
154    if let Some(d) = dir {
155        cmd.current_dir(d);
156    }
157    let out = cmd.output().ok()?;
158    out.status
159        .success()
160        .then(|| PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()))
161}
162
163/// Count of shared leading path components.
164fn shared_prefix(a: &Path, b: &Path) -> usize {
165    a.components()
166        .zip(b.components())
167        .take_while(|(x, y)| x == y)
168        .count()
169}
170
171/// graphql-ruby query candidates for a record, highest-priority first. Uses
172/// rq's qualified syntax (`Class#method`, `Module::Class`).
173pub struct Candidate {
174    pub query: String,
175    /// A bare name search, matching any symbol anywhere in the repo rather than
176    /// a graphql-ruby convention. Kept because it's sometimes the only thing
177    /// that hits, ranked last, and reported — its hits are guesses.
178    pub loose: bool,
179}
180
181impl Candidate {
182    fn convention(query: String) -> Self {
183        Self {
184            query,
185            loose: false,
186        }
187    }
188    fn fallback(query: String) -> Self {
189        Self { query, loose: true }
190    }
191}
192
193pub fn candidates(rec: &SchemaRecord) -> Vec<Candidate> {
194    let field = &rec.name;
195    let snake = to_snake(field);
196    let pascal = to_pascal(field);
197    let mut c = Vec::new();
198
199    match rec.kind {
200        Kind::Mutation => {
201            c.push(Candidate::convention(format!("Mutations::{pascal}")));
202            c.push(Candidate::convention(format!("{pascal}Mutation")));
203            if let Some(t) = &rec.parent {
204                // `MutationType#field`, and the bare root class name that
205                // federated subgraphs use (`…::Root::Mutation#field`).
206                c.push(Candidate::convention(format!("{t}Type#{snake}")));
207                c.push(Candidate::convention(format!("{t}#{snake}")));
208            }
209            c.push(Candidate::fallback(pascal.clone()));
210            c.push(Candidate::fallback(snake.clone()));
211        }
212        Kind::Query | Kind::Subscription => {
213            c.push(Candidate::convention(format!("Resolvers::{pascal}")));
214            c.push(Candidate::convention(format!("{pascal}Resolver")));
215            c.push(Candidate::convention(format!(
216                "Resolvers::{pascal}Resolver"
217            )));
218            if let Some(t) = &rec.parent {
219                // A federated subgraph names its root class `Query`, not
220                // `QueryType`, so try both rather than assuming the literal.
221                c.push(Candidate::convention(format!("{t}Type#{snake}")));
222                c.push(Candidate::convention(format!("{t}#{snake}")));
223            }
224            c.push(Candidate::fallback(snake.clone()));
225        }
226        Kind::Field | Kind::InputField => {
227            if let Some(t) = &rec.parent {
228                c.push(Candidate::convention(format!("{t}Type#{snake}")));
229                c.push(Candidate::convention(format!("{t}#{snake}")));
230                c.push(Candidate::convention(format!("Types::{t}Type#{snake}")));
231            }
232            c.push(Candidate::fallback(snake.clone()));
233        }
234        // a type: jump to its `XType` / `Types::X` class
235        Kind::Object
236        | Kind::Interface
237        | Kind::InputObject
238        | Kind::Union
239        | Kind::Enum
240        | Kind::Scalar => {
241            let t = &rec.name;
242            c.push(Candidate::convention(format!("{t}Type")));
243            c.push(Candidate::convention(format!("Types::{t}")));
244            c.push(Candidate::convention(format!("Types::{t}Type")));
245            c.push(Candidate::fallback(t.clone()));
246        }
247        Kind::EnumValue | Kind::Directive => {
248            c.push(Candidate::fallback(pascal.clone()));
249            c.push(Candidate::fallback(snake.clone()));
250        }
251    }
252    c
253}
254
255fn run_rq(query: &str, dir: Option<&str>) -> Result<Vec<RqHit>> {
256    let verbose = crate::logging::is_verbose();
257    let mut cmd = Command::new("rq");
258    // Ten, not five: the correct definition was being cut inside rq before
259    // gqls could rank it — two structurally identical fields differed only in
260    // where they fell in one noisy list. The final `limit` still applies.
261    cmd.arg("--json").arg("--limit").arg("10");
262    // Mirror `gqls -v` into rq: it traces its own decisions (root, coverage,
263    // warming) to stderr, which we let stream straight to the terminal. Without
264    // -v we keep capturing rq's stderr so a failure can surface its message.
265    if verbose {
266        cmd.arg("--verbose");
267    }
268    cmd.arg(query);
269    cmd.stdout(Stdio::piped());
270    cmd.stderr(if verbose {
271        Stdio::inherit()
272    } else {
273        Stdio::piped()
274    });
275    // rq searches the *current repo*, so run it from the server code dir (else
276    // gqls's own cwd) rather than passing a cross-repo path filter.
277    if let Some(d) = dir {
278        cmd.current_dir(d);
279    }
280    let output = match cmd.spawn() {
281        Ok(child) => child
282            .wait_with_output()
283            .map_err(|e| anyhow!("running rq: {e}"))?,
284        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
285            "--resolve needs `rq` (a code-navigation CLI), which isn't installed. Install it:\n  \
286             brew install dpep/tools/rq\n  \
287             cargo install --git https://github.com/dpep/rq\n\
288             Then re-run — see https://github.com/dpep/rq"
289        ),
290        Err(e) => bail!("running rq: {e}"),
291    };
292    // rq: exit 0 = hits (JSON array), 1 = no match (a `{status}` object) — both
293    // are normal. Any other code is a real rq failure (not a repo, bad flag):
294    // surface it instead of silently reporting "no resolver found".
295    match output.status.code() {
296        Some(0) | Some(1) => Ok(serde_json::from_slice(&output.stdout).unwrap_or_default()),
297        other => {
298            let msg = String::from_utf8_lossy(&output.stderr);
299            bail!(
300                "rq failed (exit {other:?}) for {query:?}: {}",
301                msg.lines().next().unwrap_or("").trim()
302            )
303        }
304    }
305}
306
307/// GraphQL `nameWithOwner` → Ruby `name_with_owner`.
308fn to_snake(s: &str) -> String {
309    let mut out = String::new();
310    for (i, c) in s.chars().enumerate() {
311        if c.is_uppercase() {
312            if i > 0 {
313                out.push('_');
314            }
315            out.extend(c.to_lowercase());
316        } else {
317            out.push(c);
318        }
319    }
320    out
321}
322
323/// GraphQL `createUser` → Ruby class stem `CreateUser`.
324fn to_pascal(s: &str) -> String {
325    let mut chars = s.chars();
326    match chars.next() {
327        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
328        None => String::new(),
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
337        SchemaRecord {
338            path: parent.map_or_else(|| name.into(), |p| format!("{p}.{name}")),
339            name: name.into(),
340            kind,
341            parent: parent.map(String::from),
342            type_ref: None,
343            args: vec![],
344            description: None,
345            deprecated: None,
346            directives: vec![],
347            possible_types: vec![],
348        }
349    }
350
351    #[test]
352    fn casing() {
353        assert_eq!(to_snake("nameWithOwner"), "name_with_owner");
354        assert_eq!(to_pascal("createUser"), "CreateUser");
355    }
356
357    /// The queries a record produces, in order.
358    fn queries(rec: &SchemaRecord) -> Vec<String> {
359        candidates(rec).into_iter().map(|c| c.query).collect()
360    }
361
362    #[test]
363    fn root_query_prefers_resolver_then_type_method() {
364        let c = queries(&rec("user", Some("Query"), Kind::Query));
365        assert_eq!(c[0], "Resolvers::User");
366        assert!(c.contains(&"QueryType#user".to_string()));
367        // a federated subgraph's root class is `Query`, not `QueryType`
368        assert!(c.contains(&"Query#user".to_string()), "{c:?}");
369    }
370
371    #[test]
372    fn mutation_prefers_mutation_class() {
373        let c = queries(&rec("createUser", Some("Mutation"), Kind::Mutation));
374        assert_eq!(c[0], "Mutations::CreateUser");
375    }
376
377    fn hit(parent: Option<&str>) -> RqHit {
378        RqHit {
379            name: "user".into(),
380            file: "f.rb".into(),
381            line: 1,
382            kind: "method".into(),
383            confidence: 0.5,
384            parent: parent.map(Into::into),
385            via: String::new(),
386            loose: false,
387            proximity: 0,
388            candidate_rank: 0,
389        }
390    }
391
392    #[test]
393    fn a_hit_outside_the_namespace_asked_for_is_not_a_convention_match() {
394        // rq is fuzzy: `Resolvers::User` returns a bare `class User` in
395        // app/models. The name matches; the convention was not met.
396        assert!(!satisfies("Resolvers::User", &hit(None)));
397        assert!(satisfies("Resolvers::User", &hit(Some("Resolvers"))));
398
399        // a federated root class satisfies `Query#user` even when deeply nested
400        assert!(satisfies(
401            "Query#user",
402            &hit(Some("SomeSubgraph::Schema::Root::Query"))
403        ));
404        assert!(!satisfies("Query#user", &hit(Some("Types::UserType"))));
405
406        // a bare candidate qualifies nothing, so there's nothing to verify
407        assert!(satisfies("user", &hit(None)));
408    }
409
410    #[test]
411    fn bare_name_searches_are_marked_loose_and_come_last() {
412        let c = candidates(&rec("user", Some("Query"), Kind::Query));
413        let first_loose = c.iter().position(|c| c.loose).expect("a fallback exists");
414        // every convention is tried before any bare-name guess
415        assert!(
416            c[..first_loose].iter().all(|c| !c.loose),
417            "{:?}",
418            c.iter().map(|c| (&c.query, c.loose)).collect::<Vec<_>>()
419        );
420        assert!(c[first_loose..].iter().all(|c| c.loose));
421        assert_eq!(c[first_loose].query, "user");
422    }
423
424    #[test]
425    fn object_field_targets_the_type_method() {
426        let c = queries(&rec("nameWithOwner", Some("Repository"), Kind::Field));
427        assert_eq!(c[0], "RepositoryType#name_with_owner");
428    }
429}