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(skip)]
41 candidate_rank: usize,
42}
43
44pub fn resolve(
48 rec: &SchemaRecord,
49 code_dir: Option<&str>,
50 schema_path: Option<&Path>,
51 limit: usize,
52) -> Result<Vec<RqHit>> {
53 let mut seen = HashSet::new();
56 let mut hits: Vec<RqHit> = Vec::new();
57 for (idx, cand) in candidates(rec).into_iter().enumerate() {
58 let found = run_rq(&cand, code_dir)?;
59 crate::detail!("rq candidate {:?} -> {} hit(s)", cand, found.len());
60 for mut hit in found {
61 if seen.insert(format!("{}:{}", hit.file, hit.line)) {
62 hit.via = cand.clone();
63 hit.candidate_rank = idx;
64 hits.push(hit);
65 }
66 }
67 }
68
69 if let Some(schema_dir) = schema_path
73 .and_then(|p| std::fs::canonicalize(p).ok())
74 .and_then(|p| p.parent().map(Path::to_path_buf))
75 {
76 if let Some(root) = git_toplevel(code_dir).and_then(|r| std::fs::canonicalize(r).ok()) {
77 for h in &mut hits {
78 let file_abs = root.join(&h.file);
79 let hit_dir = file_abs.parent().unwrap_or(&root);
80 h.proximity = shared_prefix(&schema_dir, hit_dir);
81 }
82 }
83 }
84
85 hits.sort_by(|a, b| {
86 b.proximity
87 .cmp(&a.proximity)
88 .then(a.candidate_rank.cmp(&b.candidate_rank))
89 .then(b.confidence.total_cmp(&a.confidence))
90 });
91 hits.truncate(limit);
92 Ok(hits)
93}
94
95fn git_toplevel(dir: Option<&str>) -> Option<PathBuf> {
98 let mut cmd = Command::new("git");
99 cmd.args(["rev-parse", "--show-toplevel"]);
100 if let Some(d) = dir {
101 cmd.current_dir(d);
102 }
103 let out = cmd.output().ok()?;
104 out.status
105 .success()
106 .then(|| PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()))
107}
108
109fn shared_prefix(a: &Path, b: &Path) -> usize {
111 a.components()
112 .zip(b.components())
113 .take_while(|(x, y)| x == y)
114 .count()
115}
116
117pub fn candidates(rec: &SchemaRecord) -> Vec<String> {
120 let field = &rec.name;
121 let snake = to_snake(field);
122 let pascal = to_pascal(field);
123 let mut c = Vec::new();
124
125 match rec.kind {
126 Kind::Mutation => {
127 c.push(format!("Mutations::{pascal}"));
128 c.push(pascal.clone());
129 c.push(format!("{pascal}Mutation"));
130 if let Some(t) = &rec.parent {
131 c.push(format!("{t}Type#{snake}"));
132 }
133 c.push(snake.clone());
134 }
135 Kind::Query | Kind::Subscription => {
136 c.push(format!("Resolvers::{pascal}"));
137 c.push(format!("{pascal}Resolver"));
138 c.push(format!("Resolvers::{pascal}Resolver"));
139 if let Some(t) = &rec.parent {
140 c.push(format!("{t}Type#{snake}"));
141 }
142 c.push(snake.clone());
143 }
144 Kind::Field | Kind::InputField => {
145 if let Some(t) = &rec.parent {
146 c.push(format!("{t}Type#{snake}"));
147 c.push(format!("{t}#{snake}"));
148 c.push(format!("Types::{t}Type#{snake}"));
149 }
150 c.push(snake.clone());
151 }
152 Kind::Object
154 | Kind::Interface
155 | Kind::InputObject
156 | Kind::Union
157 | Kind::Enum
158 | Kind::Scalar => {
159 let t = &rec.name;
160 c.push(format!("{t}Type"));
161 c.push(format!("Types::{t}"));
162 c.push(format!("Types::{t}Type"));
163 c.push(t.clone());
164 }
165 Kind::EnumValue | Kind::Directive => {
166 c.push(pascal.clone());
167 c.push(snake.clone());
168 }
169 }
170 c
171}
172
173fn run_rq(query: &str, dir: Option<&str>) -> Result<Vec<RqHit>> {
174 let verbose = crate::logging::is_verbose();
175 let mut cmd = Command::new("rq");
176 cmd.arg("--json").arg("--limit").arg("5");
177 if verbose {
181 cmd.arg("--verbose");
182 }
183 cmd.arg(query);
184 cmd.stdout(Stdio::piped());
185 cmd.stderr(if verbose {
186 Stdio::inherit()
187 } else {
188 Stdio::piped()
189 });
190 if let Some(d) = dir {
193 cmd.current_dir(d);
194 }
195 let output = match cmd.spawn() {
196 Ok(child) => child
197 .wait_with_output()
198 .map_err(|e| anyhow!("running rq: {e}"))?,
199 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
200 "--resolve needs `rq` (a code-navigation CLI), which isn't installed. Install it:\n \
201 brew install dpep/tools/rq\n \
202 cargo install --git https://github.com/dpep/rq\n\
203 Then re-run — see https://github.com/dpep/rq"
204 ),
205 Err(e) => bail!("running rq: {e}"),
206 };
207 match output.status.code() {
211 Some(0) | Some(1) => Ok(serde_json::from_slice(&output.stdout).unwrap_or_default()),
212 other => {
213 let msg = String::from_utf8_lossy(&output.stderr);
214 bail!(
215 "rq failed (exit {other:?}) for {query:?}: {}",
216 msg.lines().next().unwrap_or("").trim()
217 )
218 }
219 }
220}
221
222fn to_snake(s: &str) -> String {
224 let mut out = String::new();
225 for (i, c) in s.chars().enumerate() {
226 if c.is_uppercase() {
227 if i > 0 {
228 out.push('_');
229 }
230 out.extend(c.to_lowercase());
231 } else {
232 out.push(c);
233 }
234 }
235 out
236}
237
238fn to_pascal(s: &str) -> String {
240 let mut chars = s.chars();
241 match chars.next() {
242 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
243 None => String::new(),
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
252 SchemaRecord {
253 path: parent.map_or_else(|| name.into(), |p| format!("{p}.{name}")),
254 name: name.into(),
255 kind,
256 parent: parent.map(String::from),
257 type_ref: None,
258 args: vec![],
259 description: None,
260 deprecated: None,
261 directives: vec![],
262 }
263 }
264
265 #[test]
266 fn casing() {
267 assert_eq!(to_snake("nameWithOwner"), "name_with_owner");
268 assert_eq!(to_pascal("createUser"), "CreateUser");
269 }
270
271 #[test]
272 fn root_query_prefers_resolver_then_type_method() {
273 let c = candidates(&rec("user", Some("Query"), Kind::Query));
274 assert_eq!(c[0], "Resolvers::User");
275 assert!(c.contains(&"QueryType#user".to_string()));
276 }
277
278 #[test]
279 fn mutation_prefers_mutation_class() {
280 let c = candidates(&rec("createUser", Some("Mutation"), Kind::Mutation));
281 assert_eq!(c[0], "Mutations::CreateUser");
282 }
283
284 #[test]
285 fn object_field_targets_the_type_method() {
286 let c = candidates(&rec("nameWithOwner", Some("Repository"), Kind::Field));
287 assert_eq!(c[0], "RepositoryType#name_with_owner");
288 }
289}