1use 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#[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 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 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
93fn 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
107fn 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
115pub 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 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 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 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
202fn 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
218fn 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}