1use anyhow::Result;
4use clap::{CommandFactory, Parser};
5use clap_complete::{generate, Shell};
6use serde::Serialize;
7
8use crate::load;
9use crate::model::{Kind, SchemaRecord};
10use crate::search;
11
12const HIDE_SEMANTIC: bool = !cfg!(feature = "_semantic");
15
16#[cfg(feature = "_semantic")]
17const EXAMPLES: &str = "\
18EXAMPLES:
19 gqls user schema.graphql fuzzy search an SDL file
20 gqls createUser -k mutation restrict to a kind (schema auto-discovered)
21 gqls User.email qualified Type.field query
22 gqls repo schema.json search a local introspection dump
23 gqls repo https://api/graphql introspect a live endpoint
24 gqls 'cancel a subscription' rank by meaning (fuzzy + semantic, auto)
25 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
26 gqls user schema.graphql -j JSON output (-J for ndjson)
27";
28
29#[cfg(not(feature = "_semantic"))]
30const EXAMPLES: &str = "\
31EXAMPLES:
32 gqls user schema.graphql fuzzy search an SDL file
33 gqls createUser -k mutation restrict to a kind (schema auto-discovered)
34 gqls User.email qualified Type.field query
35 gqls repo schema.json search a local introspection dump
36 gqls repo https://api/graphql introspect a live endpoint
37 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
38 gqls user schema.graphql -j JSON output (-J for ndjson)
39
40Semantic search (-s, rank by meaning) is not compiled into this build. Enable it:
41 cargo install gqls-cli --features semantic
42 brew install dpep/tools/gqls
43";
44
45#[derive(Parser)]
46#[command(
47 name = "gqls",
48 version,
49 about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
50 long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
51 terminal. The source is an SDL file, a local introspection JSON dump, or a live \
52 http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
53 Fuzzy and semantic results are ranked together by default (--semantic or \
54 --fuzzy forces one); --resolve jumps to the graphql-ruby resolver via rq. All modes \
55 support -j/--json and -J/--ndjson.",
56 after_help = EXAMPLES
57)]
58struct Cli {
59 #[arg(required_unless_present_any = ["clear_cache", "completions", "warm"])]
62 query: Option<String>,
63
64 source: Option<String>,
68
69 #[arg(short, long)]
71 kind: Option<String>,
72
73 #[arg(short, long, default_value_t = 20)]
75 limit: usize,
76
77 #[arg(short, long, conflicts_with = "ndjson")]
79 json: bool,
80
81 #[arg(short = 'J', long)]
83 ndjson: bool,
84
85 #[arg(long, hide = HIDE_SEMANTIC)]
88 semantic: bool,
89
90 #[arg(long, conflicts_with = "semantic")]
92 fuzzy: bool,
93
94 #[arg(long, hide = HIDE_SEMANTIC)]
97 model: Option<String>,
98
99 #[arg(long, hide = HIDE_SEMANTIC)]
103 refresh: bool,
104
105 #[arg(long, hide = HIDE_SEMANTIC)]
107 clear_cache: bool,
108
109 #[arg(long, hide = HIDE_SEMANTIC)]
111 warm: bool,
112
113 #[arg(long, value_name = "SHELL")]
115 completions: Option<Shell>,
116
117 #[arg(short = 'R', long)]
120 resolve: bool,
121
122 #[arg(long)]
124 code: Option<String>,
125}
126
127#[derive(Clone, Copy)]
129enum Output {
130 Text,
131 Json,
132 Ndjson,
133}
134
135struct Match<'a> {
138 record: &'a SchemaRecord,
139 score: f64,
140}
141
142fn fuzzy_matches<'a>(
143 query: &str,
144 records: &'a [SchemaRecord],
145 kind: Option<Kind>,
146 limit: usize,
147) -> Vec<Match<'a>> {
148 search::search(query, records, kind, limit)
149 .into_iter()
150 .map(|h| Match {
151 record: h.record,
152 score: h.score as f64,
153 })
154 .collect()
155}
156
157#[cfg(feature = "_semantic")]
158fn semantic_matches<'a>(
159 query: &str,
160 records: &'a [SchemaRecord],
161 kind: Option<Kind>,
162 cli: &Cli,
163) -> Vec<Match<'a>> {
164 crate::semantic::search(query, records, kind, cli.limit, cli.model.as_deref(), cli.refresh)
165 .into_iter()
166 .map(|(score, record)| Match { record, score })
167 .collect()
168}
169
170#[cfg(feature = "_semantic")]
175fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
176 use std::collections::HashMap;
177 const K: f64 = 60.0;
178 let mut scored: HashMap<*const SchemaRecord, (f64, &SchemaRecord)> = HashMap::new();
179 for (rank, m) in fuzzy.iter().enumerate() {
180 scored.entry(m.record as *const _).or_insert((0.0, m.record)).0 += 1.0 / (K + rank as f64 + 1.0);
181 }
182 for (rank, m) in semantic.iter().enumerate() {
183 scored.entry(m.record as *const _).or_insert((0.0, m.record)).0 += 0.7 / (K + rank as f64 + 1.0);
184 }
185 let mut merged: Vec<Match> = scored
186 .into_values()
187 .map(|(score, record)| Match { record, score })
188 .collect();
189 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
190 merged.truncate(limit);
191 merged
192}
193
194#[cfg(feature = "_semantic")]
198fn spawn_background_warm(source: &str) {
199 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
200 return;
201 }
202 if let Ok(exe) = std::env::current_exe() {
203 let _ = std::process::Command::new(exe)
204 .arg("--warm")
205 .arg(source)
206 .stdin(std::process::Stdio::null())
207 .stdout(std::process::Stdio::null())
208 .stderr(std::process::Stdio::null())
209 .spawn();
210 }
211}
212
213pub fn run() -> Result<()> {
214 let cli = Cli::parse();
215
216 if let Some(shell) = cli.completions {
217 let mut cmd = Cli::command();
218 let name = cmd.get_name().to_string();
219 generate(shell, &mut cmd, name, &mut std::io::stdout());
220 return Ok(());
221 }
222
223 if cli.clear_cache {
224 #[cfg(feature = "_semantic")]
225 {
226 let n = crate::semantic::clear_cache();
227 eprintln!("gqls: cleared {n} cached vector file(s)");
228 return Ok(());
229 }
230 #[cfg(not(feature = "_semantic"))]
231 anyhow::bail!("no embedding cache in this build (built without --features semantic)");
232 }
233
234 let output = if cli.json {
235 Output::Json
236 } else if cli.ndjson {
237 Output::Ndjson
238 } else {
239 Output::Text
240 };
241
242 let kind: Option<Kind> = match &cli.kind {
243 Some(s) => Some(s.parse()?),
244 None => None,
245 };
246
247 let source = if let Some(s) = cli.source.clone() {
251 s
252 } else if cli.warm {
253 match cli.query.clone() {
254 Some(s) => s,
255 None => load::discover()?,
256 }
257 } else {
258 load::discover()?
259 };
260 let records = load::load(&source)?;
261
262 if cli.warm {
265 #[cfg(feature = "_semantic")]
266 {
267 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
268 eprintln!("gqls: warmed {n} record vector(s) into the cache");
269 return Ok(());
270 }
271 #[cfg(not(feature = "_semantic"))]
272 {
273 let _ = (&cli.model, cli.refresh);
274 anyhow::bail!("--warm needs a semantic build");
275 }
276 }
277
278 let query = cli
280 .query
281 .as_deref()
282 .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
283
284 if cli.resolve {
285 return run_resolve(query, &source, &records, kind, cli.code.as_deref(), cli.limit, output);
286 }
287
288 let matches: Vec<Match> = if cli.fuzzy {
289 fuzzy_matches(query, &records, kind, cli.limit)
290 } else if cli.semantic {
291 #[cfg(feature = "_semantic")]
292 {
293 semantic_matches(query, &records, kind, &cli)
294 }
295 #[cfg(not(feature = "_semantic"))]
296 {
297 let _ = (&cli.model, cli.refresh);
298 anyhow::bail!(
299 "this build has no semantic search — install it with \
300 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
301 );
302 }
303 } else {
304 let fuzzy = fuzzy_matches(query, &records, kind, cli.limit);
307 #[cfg(feature = "_semantic")]
308 {
309 if crate::semantic::is_cached(&records, cli.model.as_deref()) {
310 let semantic = semantic_matches(query, &records, kind, &cli);
311 combine(fuzzy, semantic, cli.limit)
312 } else {
313 spawn_background_warm(&source);
314 eprintln!(
315 "gqls: warming the semantic index in the background — the next run also \
316 ranks by meaning (--semantic to embed now, --fuzzy to skip)"
317 );
318 fuzzy
319 }
320 }
321 #[cfg(not(feature = "_semantic"))]
322 {
323 let _ = (&cli.model, cli.refresh);
324 fuzzy
325 }
326 };
327
328 if matches.is_empty() {
329 eprintln!("gqls: no matches for {query:?}");
330 }
331 output.write_matches(&matches)
332}
333
334impl Output {
335 fn write_matches(self, matches: &[Match]) -> Result<()> {
336 #[derive(Serialize)]
337 struct Row<'a> {
338 #[serde(flatten)]
339 record: &'a SchemaRecord,
340 score: f64,
341 }
342 let rows = || {
343 matches.iter().map(|m| Row {
344 record: m.record,
345 score: m.score,
346 })
347 };
348 match self {
349 Output::Json => println!("{}", serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?),
350 Output::Ndjson => {
351 for row in rows() {
352 println!("{}", serde_json::to_string(&row)?);
353 }
354 }
355 Output::Text => print_text(matches),
356 }
357 Ok(())
358 }
359}
360
361fn print_text(matches: &[Match]) {
362 let width = matches
363 .iter()
364 .map(|m| display_path(m.record).len())
365 .max()
366 .unwrap_or(0)
367 .min(48);
368
369 for m in matches {
370 let r = m.record;
371 let path = display_path(r);
372 let ret = r
373 .type_ref
374 .as_deref()
375 .map(|t| format!(" -> {t}"))
376 .unwrap_or_default();
377 let dep = if r.deprecated.is_some() {
378 " (deprecated)"
379 } else {
380 ""
381 };
382 println!("{path:<width$}{ret} [{kind}]{dep}", kind = r.kind.as_str());
383 }
384}
385
386fn run_resolve(
388 query: &str,
389 source: &str,
390 records: &[SchemaRecord],
391 kind: Option<Kind>,
392 code: Option<&str>,
393 limit: usize,
394 output: Output,
395) -> Result<()> {
396 if code.is_none() {
397 eprintln!(
398 "gqls: no --code given; resolving against rq's index for the current directory"
399 );
400 }
401 let Some(top) = search::search(query, records, kind, 1).into_iter().next() else {
402 anyhow::bail!("no schema entity matches {query:?} to resolve");
403 };
404 eprintln!("gqls: resolving {} …", top.record.path);
405 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
407 .then(|| std::path::Path::new(source))
408 .filter(|p| p.exists());
409 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
410
411 match output {
412 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
413 Output::Ndjson => {
414 for h in &hits {
415 println!("{}", serde_json::to_string(h)?);
416 }
417 }
418 Output::Text => {
419 if hits.is_empty() {
420 eprintln!(
421 "gqls: no code definition found for {} (tried graphql-ruby rq candidates)",
422 top.record.path
423 );
424 }
425 for h in &hits {
426 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
427 }
428 }
429 }
430 Ok(())
431}
432
433fn display_path(r: &SchemaRecord) -> String {
435 if r.args.is_empty() {
436 r.path.clone()
437 } else {
438 format!("{}({})", r.path, r.args.join(", "))
439 }
440}