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::HashSet;
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 graphql-ruby candidate query that surfaced this hit — set by gqls,
30    /// not read from rq, but included in serialized output.
31    #[serde(default, skip_deserializing)]
32    pub via: String,
33    /// Shared leading path components between the schema's package and this
34    /// hit's file — the primary rank key when a file schema is known, so a
35    /// resolver in the schema's own subgraph outranks a same-named one
36    /// elsewhere in the monorepo.
37    #[serde(default, skip_deserializing)]
38    pub proximity: usize,
39    /// Whether the candidate that found this was a bare name search rather
40    /// than a graphql-ruby convention — a guess, and labelled as one.
41    #[serde(default, skip_deserializing)]
42    pub loose: bool,
43    /// Index of the candidate query that surfaced this hit (0 = best).
44    #[serde(skip)]
45    candidate_rank: usize,
46}
47
48/// Resolve `rec` to its code definition(s) via rq, best first. `schema_path`,
49/// when a local file, ranks hits by package proximity to the schema — the
50/// resolver in the schema's own subgraph wins over a same-named one elsewhere.
51pub fn resolve(
52    rec: &SchemaRecord,
53    code_dir: Option<&str>,
54    schema_path: Option<&Path>,
55    limit: usize,
56) -> Result<Vec<RqHit>> {
57    // Collect every candidate's hits first (deduped), then rank as a whole —
58    // proximity can only reorder across candidates if we have them all.
59    let mut seen = HashSet::new();
60    let mut hits: Vec<RqHit> = Vec::new();
61    for (idx, cand) in candidates(rec).into_iter().enumerate() {
62        let found = run_rq(&cand.query, code_dir)?;
63        crate::detail!("rq candidate {:?} -> {} hit(s)", cand.query, found.len());
64        for mut hit in found {
65            if seen.insert(format!("{}:{}", hit.file, hit.line)) {
66                hit.via = cand.query.clone();
67                hit.candidate_rank = idx;
68                hit.loose = cand.loose;
69                hits.push(hit);
70            }
71        }
72    }
73
74    // Package proximity: shared leading path components between the schema's
75    // directory and each hit's file. rq paths are repo-root-relative, so join
76    // them onto the code repo's git toplevel to compare absolute paths.
77    if let Some(schema_dir) = schema_path
78        .and_then(|p| std::fs::canonicalize(p).ok())
79        .and_then(|p| p.parent().map(Path::to_path_buf))
80    {
81        if let Some(root) = git_toplevel(code_dir).and_then(|r| std::fs::canonicalize(r).ok()) {
82            for h in &mut hits {
83                let file_abs = root.join(&h.file);
84                let hit_dir = file_abs.parent().unwrap_or(&root);
85                h.proximity = shared_prefix(&schema_dir, hit_dir);
86            }
87        }
88    }
89
90    // Which convention matched comes first: a hit from `Mutations::VerbNoun`
91    // is evidence, while proximity is a tiebreak between equally-good matches.
92    // Ranking on proximity first let five unrelated files that happen to sit
93    // one directory closer bury a confident match.
94    hits.sort_by(|a, b| {
95        a.loose
96            .cmp(&b.loose)
97            .then(a.candidate_rank.cmp(&b.candidate_rank))
98            .then(b.proximity.cmp(&a.proximity))
99            .then(b.confidence.total_cmp(&a.confidence))
100    });
101    hits.truncate(limit);
102    Ok(hits)
103}
104
105/// The git repo root containing `dir` (or gqls's cwd) — rq's file paths are
106/// relative to it.
107fn git_toplevel(dir: Option<&str>) -> Option<PathBuf> {
108    let mut cmd = Command::new("git");
109    cmd.args(["rev-parse", "--show-toplevel"]);
110    if let Some(d) = dir {
111        cmd.current_dir(d);
112    }
113    let out = cmd.output().ok()?;
114    out.status
115        .success()
116        .then(|| PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()))
117}
118
119/// Count of shared leading path components.
120fn shared_prefix(a: &Path, b: &Path) -> usize {
121    a.components()
122        .zip(b.components())
123        .take_while(|(x, y)| x == y)
124        .count()
125}
126
127/// graphql-ruby query candidates for a record, highest-priority first. Uses
128/// rq's qualified syntax (`Class#method`, `Module::Class`).
129pub struct Candidate {
130    pub query: String,
131    /// A bare name search, matching any symbol anywhere in the repo rather than
132    /// a graphql-ruby convention. Kept because it's sometimes the only thing
133    /// that hits, ranked last, and reported — its hits are guesses.
134    pub loose: bool,
135}
136
137impl Candidate {
138    fn convention(query: String) -> Self {
139        Self {
140            query,
141            loose: false,
142        }
143    }
144    fn fallback(query: String) -> Self {
145        Self { query, loose: true }
146    }
147}
148
149pub fn candidates(rec: &SchemaRecord) -> Vec<Candidate> {
150    let field = &rec.name;
151    let snake = to_snake(field);
152    let pascal = to_pascal(field);
153    let mut c = Vec::new();
154
155    match rec.kind {
156        Kind::Mutation => {
157            c.push(Candidate::convention(format!("Mutations::{pascal}")));
158            c.push(Candidate::convention(format!("{pascal}Mutation")));
159            if let Some(t) = &rec.parent {
160                // `MutationType#field`, and the bare root class name that
161                // federated subgraphs use (`…::Root::Mutation#field`).
162                c.push(Candidate::convention(format!("{t}Type#{snake}")));
163                c.push(Candidate::convention(format!("{t}#{snake}")));
164            }
165            c.push(Candidate::fallback(pascal.clone()));
166            c.push(Candidate::fallback(snake.clone()));
167        }
168        Kind::Query | Kind::Subscription => {
169            c.push(Candidate::convention(format!("Resolvers::{pascal}")));
170            c.push(Candidate::convention(format!("{pascal}Resolver")));
171            c.push(Candidate::convention(format!(
172                "Resolvers::{pascal}Resolver"
173            )));
174            if let Some(t) = &rec.parent {
175                // A federated subgraph names its root class `Query`, not
176                // `QueryType`, so try both rather than assuming the literal.
177                c.push(Candidate::convention(format!("{t}Type#{snake}")));
178                c.push(Candidate::convention(format!("{t}#{snake}")));
179            }
180            c.push(Candidate::fallback(snake.clone()));
181        }
182        Kind::Field | Kind::InputField => {
183            if let Some(t) = &rec.parent {
184                c.push(Candidate::convention(format!("{t}Type#{snake}")));
185                c.push(Candidate::convention(format!("{t}#{snake}")));
186                c.push(Candidate::convention(format!("Types::{t}Type#{snake}")));
187            }
188            c.push(Candidate::fallback(snake.clone()));
189        }
190        // a type: jump to its `XType` / `Types::X` class
191        Kind::Object
192        | Kind::Interface
193        | Kind::InputObject
194        | Kind::Union
195        | Kind::Enum
196        | Kind::Scalar => {
197            let t = &rec.name;
198            c.push(Candidate::convention(format!("{t}Type")));
199            c.push(Candidate::convention(format!("Types::{t}")));
200            c.push(Candidate::convention(format!("Types::{t}Type")));
201            c.push(Candidate::fallback(t.clone()));
202        }
203        Kind::EnumValue | Kind::Directive => {
204            c.push(Candidate::fallback(pascal.clone()));
205            c.push(Candidate::fallback(snake.clone()));
206        }
207    }
208    c
209}
210
211fn run_rq(query: &str, dir: Option<&str>) -> Result<Vec<RqHit>> {
212    let verbose = crate::logging::is_verbose();
213    let mut cmd = Command::new("rq");
214    // Ten, not five: the correct definition was being cut inside rq before
215    // gqls could rank it — two structurally identical fields differed only in
216    // where they fell in one noisy list. The final `limit` still applies.
217    cmd.arg("--json").arg("--limit").arg("10");
218    // Mirror `gqls -v` into rq: it traces its own decisions (root, coverage,
219    // warming) to stderr, which we let stream straight to the terminal. Without
220    // -v we keep capturing rq's stderr so a failure can surface its message.
221    if verbose {
222        cmd.arg("--verbose");
223    }
224    cmd.arg(query);
225    cmd.stdout(Stdio::piped());
226    cmd.stderr(if verbose {
227        Stdio::inherit()
228    } else {
229        Stdio::piped()
230    });
231    // rq searches the *current repo*, so run it from the server code dir (else
232    // gqls's own cwd) rather than passing a cross-repo path filter.
233    if let Some(d) = dir {
234        cmd.current_dir(d);
235    }
236    let output = match cmd.spawn() {
237        Ok(child) => child
238            .wait_with_output()
239            .map_err(|e| anyhow!("running rq: {e}"))?,
240        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
241            "--resolve needs `rq` (a code-navigation CLI), which isn't installed. Install it:\n  \
242             brew install dpep/tools/rq\n  \
243             cargo install --git https://github.com/dpep/rq\n\
244             Then re-run — see https://github.com/dpep/rq"
245        ),
246        Err(e) => bail!("running rq: {e}"),
247    };
248    // rq: exit 0 = hits (JSON array), 1 = no match (a `{status}` object) — both
249    // are normal. Any other code is a real rq failure (not a repo, bad flag):
250    // surface it instead of silently reporting "no resolver found".
251    match output.status.code() {
252        Some(0) | Some(1) => Ok(serde_json::from_slice(&output.stdout).unwrap_or_default()),
253        other => {
254            let msg = String::from_utf8_lossy(&output.stderr);
255            bail!(
256                "rq failed (exit {other:?}) for {query:?}: {}",
257                msg.lines().next().unwrap_or("").trim()
258            )
259        }
260    }
261}
262
263/// GraphQL `nameWithOwner` → Ruby `name_with_owner`.
264fn to_snake(s: &str) -> String {
265    let mut out = String::new();
266    for (i, c) in s.chars().enumerate() {
267        if c.is_uppercase() {
268            if i > 0 {
269                out.push('_');
270            }
271            out.extend(c.to_lowercase());
272        } else {
273            out.push(c);
274        }
275    }
276    out
277}
278
279/// GraphQL `createUser` → Ruby class stem `CreateUser`.
280fn to_pascal(s: &str) -> String {
281    let mut chars = s.chars();
282    match chars.next() {
283        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
284        None => String::new(),
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
293        SchemaRecord {
294            path: parent.map_or_else(|| name.into(), |p| format!("{p}.{name}")),
295            name: name.into(),
296            kind,
297            parent: parent.map(String::from),
298            type_ref: None,
299            args: vec![],
300            description: None,
301            deprecated: None,
302            directives: vec![],
303            possible_types: vec![],
304        }
305    }
306
307    #[test]
308    fn casing() {
309        assert_eq!(to_snake("nameWithOwner"), "name_with_owner");
310        assert_eq!(to_pascal("createUser"), "CreateUser");
311    }
312
313    /// The queries a record produces, in order.
314    fn queries(rec: &SchemaRecord) -> Vec<String> {
315        candidates(rec).into_iter().map(|c| c.query).collect()
316    }
317
318    #[test]
319    fn root_query_prefers_resolver_then_type_method() {
320        let c = queries(&rec("user", Some("Query"), Kind::Query));
321        assert_eq!(c[0], "Resolvers::User");
322        assert!(c.contains(&"QueryType#user".to_string()));
323        // a federated subgraph's root class is `Query`, not `QueryType`
324        assert!(c.contains(&"Query#user".to_string()), "{c:?}");
325    }
326
327    #[test]
328    fn mutation_prefers_mutation_class() {
329        let c = queries(&rec("createUser", Some("Mutation"), Kind::Mutation));
330        assert_eq!(c[0], "Mutations::CreateUser");
331    }
332
333    #[test]
334    fn bare_name_searches_are_marked_loose_and_come_last() {
335        let c = candidates(&rec("user", Some("Query"), Kind::Query));
336        let first_loose = c.iter().position(|c| c.loose).expect("a fallback exists");
337        // every convention is tried before any bare-name guess
338        assert!(
339            c[..first_loose].iter().all(|c| !c.loose),
340            "{:?}",
341            c.iter().map(|c| (&c.query, c.loose)).collect::<Vec<_>>()
342        );
343        assert!(c[first_loose..].iter().all(|c| c.loose));
344        assert_eq!(c[first_loose].query, "user");
345    }
346
347    #[test]
348    fn object_field_targets_the_type_method() {
349        let c = queries(&rec("nameWithOwner", Some("Repository"), Kind::Field));
350        assert_eq!(c[0], "RepositoryType#name_with_owner");
351    }
352}