1use 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#[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)]
32 pub parent: Option<String>,
33 #[serde(default, skip_deserializing)]
36 pub via: String,
37 #[serde(default, skip_deserializing)]
42 pub proximity: usize,
43 #[serde(default, skip_deserializing)]
46 pub loose: bool,
47 #[serde(skip)]
49 candidate_rank: usize,
50}
51
52pub fn resolve(
56 rec: &SchemaRecord,
57 code_dir: Option<&str>,
58 schema_path: Option<&Path>,
59 limit: usize,
60) -> Result<Vec<RqHit>> {
61 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 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 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 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 .then_with(|| (&a.file, a.line).cmp(&(&b.file, b.line)))
119 });
120 hits.truncate(limit);
121 Ok(hits)
122}
123
124fn 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 qualifier
145 .split("::")
146 .all(|want| parent.split("::").any(|have| have == want))
147}
148
149fn 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
163fn 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
171pub struct Candidate {
174 pub query: String,
175 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 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 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 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 cmd.arg("--json").arg("--limit").arg("10");
262 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 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 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
307fn 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
323fn 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 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 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 assert!(!satisfies("Resolvers::User", &hit(None)));
397 assert!(satisfies("Resolvers::User", &hit(Some("Resolvers"))));
398
399 assert!(satisfies(
401 "Query#user",
402 &hit(Some("SomeSubgraph::Schema::Root::Query"))
403 ));
404 assert!(!satisfies("Query#user", &hit(Some("Types::UserType"))));
405
406 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 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}