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;
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    /// Index of the candidate query that surfaced this hit (0 = best).
40    #[serde(skip)]
41    candidate_rank: usize,
42}
43
44/// Resolve `rec` to its code definition(s) via rq, best first. `schema_path`,
45/// when a local file, ranks hits by package proximity to the schema — the
46/// resolver in the schema's own subgraph wins over a same-named one elsewhere.
47pub fn resolve(
48    rec: &SchemaRecord,
49    code_dir: Option<&str>,
50    schema_path: Option<&Path>,
51    limit: usize,
52) -> Result<Vec<RqHit>> {
53    // Collect every candidate's hits first (deduped), then rank as a whole —
54    // proximity can only reorder across candidates if we have them all.
55    let mut seen = HashSet::new();
56    let mut hits: Vec<RqHit> = Vec::new();
57    for (idx, cand) in candidates(rec).into_iter().enumerate() {
58        for mut hit in run_rq(&cand, code_dir)? {
59            if seen.insert(format!("{}:{}", hit.file, hit.line)) {
60                hit.via = cand.clone();
61                hit.candidate_rank = idx;
62                hits.push(hit);
63            }
64        }
65    }
66
67    // Package proximity: shared leading path components between the schema's
68    // directory and each hit's file. rq paths are repo-root-relative, so join
69    // them onto the code repo's git toplevel to compare absolute paths.
70    if let Some(schema_dir) = schema_path
71        .and_then(|p| std::fs::canonicalize(p).ok())
72        .and_then(|p| p.parent().map(Path::to_path_buf))
73    {
74        if let Some(root) = git_toplevel(code_dir).and_then(|r| std::fs::canonicalize(r).ok()) {
75            for h in &mut hits {
76                let file_abs = root.join(&h.file);
77                let hit_dir = file_abs.parent().unwrap_or(&root);
78                h.proximity = shared_prefix(&schema_dir, hit_dir);
79            }
80        }
81    }
82
83    hits.sort_by(|a, b| {
84        b.proximity
85            .cmp(&a.proximity)
86            .then(a.candidate_rank.cmp(&b.candidate_rank))
87            .then(b.confidence.total_cmp(&a.confidence))
88    });
89    hits.truncate(limit);
90    Ok(hits)
91}
92
93/// The git repo root containing `dir` (or gqls's cwd) — rq's file paths are
94/// relative to it.
95fn git_toplevel(dir: Option<&str>) -> Option<PathBuf> {
96    let mut cmd = Command::new("git");
97    cmd.args(["rev-parse", "--show-toplevel"]);
98    if let Some(d) = dir {
99        cmd.current_dir(d);
100    }
101    let out = cmd.output().ok()?;
102    out.status
103        .success()
104        .then(|| PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()))
105}
106
107/// Count of shared leading path components.
108fn shared_prefix(a: &Path, b: &Path) -> usize {
109    a.components()
110        .zip(b.components())
111        .take_while(|(x, y)| x == y)
112        .count()
113}
114
115/// graphql-ruby query candidates for a record, highest-priority first. Uses
116/// rq's qualified syntax (`Class#method`, `Module::Class`).
117pub fn candidates(rec: &SchemaRecord) -> Vec<String> {
118    let field = &rec.name;
119    let snake = to_snake(field);
120    let pascal = to_pascal(field);
121    let mut c = Vec::new();
122
123    match rec.kind {
124        Kind::Mutation => {
125            c.push(format!("Mutations::{pascal}"));
126            c.push(pascal.clone());
127            c.push(format!("{pascal}Mutation"));
128            if let Some(t) = &rec.parent {
129                c.push(format!("{t}Type#{snake}"));
130            }
131            c.push(snake.clone());
132        }
133        Kind::Query | Kind::Subscription => {
134            c.push(format!("Resolvers::{pascal}"));
135            c.push(format!("{pascal}Resolver"));
136            c.push(format!("Resolvers::{pascal}Resolver"));
137            if let Some(t) = &rec.parent {
138                c.push(format!("{t}Type#{snake}"));
139            }
140            c.push(snake.clone());
141        }
142        Kind::Field | Kind::InputField => {
143            if let Some(t) = &rec.parent {
144                c.push(format!("{t}Type#{snake}"));
145                c.push(format!("{t}#{snake}"));
146                c.push(format!("Types::{t}Type#{snake}"));
147            }
148            c.push(snake.clone());
149        }
150        // a type: jump to its `XType` / `Types::X` class
151        Kind::Object | Kind::Interface | Kind::InputObject | Kind::Union | Kind::Enum
152        | Kind::Scalar => {
153            let t = &rec.name;
154            c.push(format!("{t}Type"));
155            c.push(format!("Types::{t}"));
156            c.push(format!("Types::{t}Type"));
157            c.push(t.clone());
158        }
159        Kind::EnumValue | Kind::Directive => {
160            c.push(pascal.clone());
161            c.push(snake.clone());
162        }
163    }
164    c
165}
166
167fn run_rq(query: &str, dir: Option<&str>) -> Result<Vec<RqHit>> {
168    let mut cmd = Command::new("rq");
169    cmd.arg("--json").arg("--limit").arg("5").arg(query);
170    // rq searches the *current repo*, so run it from the server code dir (else
171    // gqls's own cwd) rather than passing a cross-repo path filter.
172    if let Some(d) = dir {
173        cmd.current_dir(d);
174    }
175    let output = cmd.output().map_err(|e| {
176        if e.kind() == std::io::ErrorKind::NotFound {
177            anyhow!(
178                "--resolve needs `rq` (a code-navigation CLI), which isn't installed. Install it:\n  \
179                 brew install dpep/tools/rq\n  \
180                 cargo install --git https://github.com/dpep/rq\n\
181                 Then re-run — see https://github.com/dpep/rq"
182            )
183        } else {
184            anyhow!("running rq: {e}")
185        }
186    })?;
187    // rq: exit 0 = hits (JSON array), 1 = no match (a `{status}` object) — both
188    // are normal. Any other code is a real rq failure (not a repo, bad flag):
189    // surface it instead of silently reporting "no resolver found".
190    match output.status.code() {
191        Some(0) | Some(1) => Ok(serde_json::from_slice(&output.stdout).unwrap_or_default()),
192        other => {
193            let msg = String::from_utf8_lossy(&output.stderr);
194            bail!(
195                "rq failed (exit {other:?}) for {query:?}: {}",
196                msg.lines().next().unwrap_or("").trim()
197            )
198        }
199    }
200}
201
202/// GraphQL `nameWithOwner` → Ruby `name_with_owner`.
203fn to_snake(s: &str) -> String {
204    let mut out = String::new();
205    for (i, c) in s.chars().enumerate() {
206        if c.is_uppercase() {
207            if i > 0 {
208                out.push('_');
209            }
210            out.extend(c.to_lowercase());
211        } else {
212            out.push(c);
213        }
214    }
215    out
216}
217
218/// GraphQL `createUser` → Ruby class stem `CreateUser`.
219fn to_pascal(s: &str) -> String {
220    let mut chars = s.chars();
221    match chars.next() {
222        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
223        None => String::new(),
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
232        SchemaRecord {
233            path: parent.map_or_else(|| name.into(), |p| format!("{p}.{name}")),
234            name: name.into(),
235            kind,
236            parent: parent.map(String::from),
237            type_ref: None,
238            args: vec![],
239            description: None,
240            deprecated: None,
241            directives: vec![],
242        }
243    }
244
245    #[test]
246    fn casing() {
247        assert_eq!(to_snake("nameWithOwner"), "name_with_owner");
248        assert_eq!(to_pascal("createUser"), "CreateUser");
249    }
250
251    #[test]
252    fn root_query_prefers_resolver_then_type_method() {
253        let c = candidates(&rec("user", Some("Query"), Kind::Query));
254        assert_eq!(c[0], "Resolvers::User");
255        assert!(c.contains(&"QueryType#user".to_string()));
256    }
257
258    #[test]
259    fn mutation_prefers_mutation_class() {
260        let c = candidates(&rec("createUser", Some("Mutation"), Kind::Mutation));
261        assert_eq!(c[0], "Mutations::CreateUser");
262    }
263
264    #[test]
265    fn object_field_targets_the_type_method() {
266        let c = candidates(&rec("nameWithOwner", Some("Repository"), Kind::Field));
267        assert_eq!(c[0], "RepositoryType#name_with_owner");
268    }
269}