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::process::Command;
12
13use anyhow::{anyhow, bail, Result};
14use serde::{Deserialize, Serialize};
15
16use crate::model::{Kind, SchemaRecord};
17
18/// One code definition rq found, plus the candidate query that surfaced it.
19#[derive(Debug, Deserialize, Serialize)]
20pub struct RqHit {
21    pub name: String,
22    pub file: String,
23    pub line: u64,
24    #[serde(default)]
25    pub kind: String,
26    #[serde(default)]
27    pub confidence: f64,
28    /// The graphql-ruby candidate query that surfaced this hit — set by gqls,
29    /// not read from rq, but included in serialized output.
30    #[serde(default, skip_deserializing)]
31    pub via: String,
32}
33
34/// Resolve `rec` to its code definition(s) via rq, best first.
35pub fn resolve(rec: &SchemaRecord, code_dir: Option<&str>, limit: usize) -> Result<Vec<RqHit>> {
36    let mut seen = HashSet::new();
37    let mut out = Vec::new();
38    for cand in candidates(rec) {
39        for mut hit in run_rq(&cand, code_dir)? {
40            if seen.insert(format!("{}:{}", hit.file, hit.line)) {
41                hit.via = cand.clone();
42                out.push(hit);
43                if out.len() >= limit {
44                    return Ok(out);
45                }
46            }
47        }
48    }
49    Ok(out)
50}
51
52/// graphql-ruby query candidates for a record, highest-priority first. Uses
53/// rq's qualified syntax (`Class#method`, `Module::Class`).
54pub fn candidates(rec: &SchemaRecord) -> Vec<String> {
55    let field = &rec.name;
56    let snake = to_snake(field);
57    let pascal = to_pascal(field);
58    let mut c = Vec::new();
59
60    match rec.kind {
61        Kind::Mutation => {
62            c.push(format!("Mutations::{pascal}"));
63            c.push(pascal.clone());
64            c.push(format!("{pascal}Mutation"));
65            if let Some(t) = &rec.parent {
66                c.push(format!("{t}Type#{snake}"));
67            }
68            c.push(snake.clone());
69        }
70        Kind::Query | Kind::Subscription => {
71            c.push(format!("Resolvers::{pascal}"));
72            c.push(format!("{pascal}Resolver"));
73            c.push(format!("Resolvers::{pascal}Resolver"));
74            if let Some(t) = &rec.parent {
75                c.push(format!("{t}Type#{snake}"));
76            }
77            c.push(snake.clone());
78        }
79        Kind::Field | Kind::InputField => {
80            if let Some(t) = &rec.parent {
81                c.push(format!("{t}Type#{snake}"));
82                c.push(format!("{t}#{snake}"));
83                c.push(format!("Types::{t}Type#{snake}"));
84            }
85            c.push(snake.clone());
86        }
87        // a type: jump to its `XType` / `Types::X` class
88        Kind::Object | Kind::Interface | Kind::InputObject | Kind::Union | Kind::Enum
89        | Kind::Scalar => {
90            let t = &rec.name;
91            c.push(format!("{t}Type"));
92            c.push(format!("Types::{t}"));
93            c.push(format!("Types::{t}Type"));
94            c.push(t.clone());
95        }
96        Kind::EnumValue | Kind::Directive => {
97            c.push(pascal.clone());
98            c.push(snake.clone());
99        }
100    }
101    c
102}
103
104fn run_rq(query: &str, dir: Option<&str>) -> Result<Vec<RqHit>> {
105    let mut cmd = Command::new("rq");
106    cmd.arg("--json").arg("--limit").arg("5").arg(query);
107    // rq searches the *current repo*, so run it from the server code dir (else
108    // gqls's own cwd) rather than passing a cross-repo path filter.
109    if let Some(d) = dir {
110        cmd.current_dir(d);
111    }
112    let output = cmd
113        .output()
114        .map_err(|e| anyhow!("running rq: {e} — is `rq` installed and on PATH?"))?;
115    // rq: exit 0 = hits (JSON array), 1 = no match (a `{status}` object) — both
116    // are normal. Any other code is a real rq failure (not a repo, bad flag):
117    // surface it instead of silently reporting "no resolver found".
118    match output.status.code() {
119        Some(0) | Some(1) => Ok(serde_json::from_slice(&output.stdout).unwrap_or_default()),
120        other => {
121            let msg = String::from_utf8_lossy(&output.stderr);
122            bail!(
123                "rq failed (exit {other:?}) for {query:?}: {}",
124                msg.lines().next().unwrap_or("").trim()
125            )
126        }
127    }
128}
129
130/// GraphQL `nameWithOwner` → Ruby `name_with_owner`.
131fn to_snake(s: &str) -> String {
132    let mut out = String::new();
133    for (i, c) in s.chars().enumerate() {
134        if c.is_uppercase() {
135            if i > 0 {
136                out.push('_');
137            }
138            out.extend(c.to_lowercase());
139        } else {
140            out.push(c);
141        }
142    }
143    out
144}
145
146/// GraphQL `createUser` → Ruby class stem `CreateUser`.
147fn to_pascal(s: &str) -> String {
148    let mut chars = s.chars();
149    match chars.next() {
150        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
151        None => String::new(),
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
160        SchemaRecord {
161            path: parent.map_or_else(|| name.into(), |p| format!("{p}.{name}")),
162            name: name.into(),
163            kind,
164            parent: parent.map(String::from),
165            type_ref: None,
166            args: vec![],
167            description: None,
168            deprecated: None,
169            directives: vec![],
170        }
171    }
172
173    #[test]
174    fn casing() {
175        assert_eq!(to_snake("nameWithOwner"), "name_with_owner");
176        assert_eq!(to_pascal("createUser"), "CreateUser");
177    }
178
179    #[test]
180    fn root_query_prefers_resolver_then_type_method() {
181        let c = candidates(&rec("user", Some("Query"), Kind::Query));
182        assert_eq!(c[0], "Resolvers::User");
183        assert!(c.contains(&"QueryType#user".to_string()));
184    }
185
186    #[test]
187    fn mutation_prefers_mutation_class() {
188        let c = candidates(&rec("createUser", Some("Mutation"), Kind::Mutation));
189        assert_eq!(c[0], "Mutations::CreateUser");
190    }
191
192    #[test]
193    fn object_field_targets_the_type_method() {
194        let c = candidates(&rec("nameWithOwner", Some("Repository"), Kind::Field));
195        assert_eq!(c[0], "RepositoryType#name_with_owner");
196    }
197}