1use 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#[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 #[serde(default, skip_deserializing)]
32 pub via: String,
33 #[serde(default, skip_deserializing)]
38 pub proximity: usize,
39 #[serde(default, skip_deserializing)]
42 pub loose: bool,
43 #[serde(skip)]
45 candidate_rank: usize,
46}
47
48pub fn resolve(
52 rec: &SchemaRecord,
53 code_dir: Option<&str>,
54 schema_path: Option<&Path>,
55 limit: usize,
56) -> Result<Vec<RqHit>> {
57 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 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 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
105fn 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
119fn 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
127pub struct Candidate {
130 pub query: String,
131 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 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 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 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 cmd.arg("--json").arg("--limit").arg("10");
218 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 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 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
263fn 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
279fn 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 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 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 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}