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 (--semantic, 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 #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
129 header: Vec<String>,
130
131 #[arg(short, long, conflicts_with = "quiet")]
134 verbose: bool,
135
136 #[arg(short, long)]
138 quiet: bool,
139}
140
141#[derive(Clone, Copy)]
143enum Output {
144 Text,
145 Json,
146 Ndjson,
147}
148
149struct Match<'a> {
152 record: &'a SchemaRecord,
153 score: f64,
154}
155
156fn fuzzy_matches<'a>(
157 query: &str,
158 records: &'a [SchemaRecord],
159 kind: Option<Kind>,
160 limit: usize,
161) -> Vec<Match<'a>> {
162 search::search(query, records, kind, limit)
163 .into_iter()
164 .map(|h| Match {
165 record: h.record,
166 score: h.score as f64,
167 })
168 .collect()
169}
170
171#[cfg(feature = "_semantic")]
172fn semantic_matches<'a>(
173 query: &str,
174 records: &'a [SchemaRecord],
175 kind: Option<Kind>,
176 cli: &Cli,
177) -> Vec<Match<'a>> {
178 crate::semantic::search(
179 query,
180 records,
181 kind,
182 cli.limit,
183 cli.model.as_deref(),
184 cli.refresh,
185 )
186 .into_iter()
187 .map(|(score, record)| Match { record, score })
188 .collect()
189}
190
191#[cfg(feature = "_semantic")]
196fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
197 use std::collections::HashMap;
198 const K: f64 = 60.0;
199 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
203 for (rank, m) in fuzzy.iter().enumerate() {
204 scored
205 .entry(m.record.path.as_str())
206 .or_insert((0.0, m.record))
207 .0 += 1.0 / (K + rank as f64 + 1.0);
208 }
209 for (rank, m) in semantic.iter().enumerate() {
210 scored
211 .entry(m.record.path.as_str())
212 .or_insert((0.0, m.record))
213 .0 += 0.7 / (K + rank as f64 + 1.0);
214 }
215 let mut merged: Vec<Match> = scored
216 .into_values()
217 .map(|(score, record)| Match { record, score })
218 .collect();
219 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
220 merged.truncate(limit);
221 merged
222}
223
224#[cfg(feature = "_semantic")]
228fn spawn_background_warm(source: &str, headers: &[String]) {
229 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
230 return;
231 }
232 if !claim_warm_lock(source) {
236 return;
237 }
238 if let Ok(exe) = std::env::current_exe() {
239 let mut cmd = std::process::Command::new(exe);
240 cmd.arg("--warm").arg(source);
241 for h in headers {
242 cmd.arg("--header").arg(h);
243 }
244 let _ = cmd
245 .stdin(std::process::Stdio::null())
246 .stdout(std::process::Stdio::null())
247 .stderr(std::process::Stdio::null())
248 .spawn();
249 }
250}
251
252#[cfg(feature = "_semantic")]
257fn claim_warm_lock(source: &str) -> bool {
258 use std::collections::hash_map::DefaultHasher;
259 use std::hash::{Hash, Hasher};
260 use std::time::Duration;
261
262 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
263 let Some(dir) = crate::paths::cache_dir() else {
264 return true; };
266 let mut h = DefaultHasher::new();
267 source.hash(&mut h);
268 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
269 if let Ok(meta) = std::fs::metadata(&lock) {
270 if let Ok(modified) = meta.modified() {
271 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
272 return false; }
274 }
275 }
276 let _ = std::fs::create_dir_all(&dir);
277 std::fs::write(&lock, []).is_ok()
278}
279
280fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
282 raw.iter()
283 .map(|h| {
284 let (name, value) = h
285 .split_once(':')
286 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
287 Ok((name.trim().to_string(), value.trim().to_string()))
288 })
289 .collect()
290}
291
292pub fn run() -> Result<()> {
293 let cli = Cli::parse();
294 crate::logging::init(cli.verbose, cli.quiet);
295
296 if let Some(shell) = cli.completions {
297 let mut cmd = Cli::command();
298 let name = cmd.get_name().to_string();
299 generate(shell, &mut cmd, name, &mut std::io::stdout());
300 return Ok(());
301 }
302
303 if cli.clear_cache {
304 let introspect = crate::load::introspect::clear_cache();
305 #[cfg(feature = "_semantic")]
306 let vectors = crate::semantic::clear_cache();
307 #[cfg(not(feature = "_semantic"))]
308 let vectors = 0;
309 crate::status!("cleared {} cached file(s)", introspect + vectors);
310 return Ok(());
311 }
312
313 let output = if cli.json {
314 Output::Json
315 } else if cli.ndjson {
316 Output::Ndjson
317 } else {
318 Output::Text
319 };
320
321 let kind: Option<Kind> = match &cli.kind {
322 Some(s) => Some(s.parse()?),
323 None => None,
324 };
325
326 let source = if let Some(s) = cli.source.clone() {
330 s
331 } else if cli.warm {
332 match cli.query.clone() {
333 Some(s) => s,
334 None => load::discover()?,
335 }
336 } else {
337 load::discover()?
338 };
339 let load_opts = load::LoadOptions {
340 headers: parse_headers(&cli.header)?,
341 refresh: cli.refresh,
342 };
343 let records = load::load(&source, &load_opts)?;
344
345 if cli.warm {
348 #[cfg(feature = "_semantic")]
349 {
350 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
351 crate::status!("warmed {n} record vector(s) into the cache");
352 return Ok(());
353 }
354 #[cfg(not(feature = "_semantic"))]
355 {
356 let _ = (&cli.model, cli.refresh);
357 anyhow::bail!("--warm needs a semantic build");
358 }
359 }
360
361 let query = cli
363 .query
364 .as_deref()
365 .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
366
367 if cli.resolve {
368 return run_resolve(
369 query,
370 &source,
371 &records,
372 kind,
373 cli.code.as_deref(),
374 cli.limit,
375 output,
376 );
377 }
378
379 let matches: Vec<Match> = if cli.fuzzy {
380 fuzzy_matches(query, &records, kind, cli.limit)
381 } else if cli.semantic {
382 #[cfg(feature = "_semantic")]
383 {
384 semantic_matches(query, &records, kind, &cli)
385 }
386 #[cfg(not(feature = "_semantic"))]
387 {
388 let _ = (&cli.model, cli.refresh);
389 anyhow::bail!(
390 "this build has no semantic search — install it with \
391 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
392 );
393 }
394 } else {
395 let fuzzy = fuzzy_matches(query, &records, kind, cli.limit);
398 #[cfg(feature = "_semantic")]
399 {
400 if crate::semantic::is_cached(&records, cli.model.as_deref()) {
401 let semantic = semantic_matches(query, &records, kind, &cli);
402 combine(fuzzy, semantic, cli.limit)
403 } else {
404 spawn_background_warm(&source, &cli.header);
405 crate::status!(
406 "warming the semantic index in the background — the next run also \
407 ranks by meaning (--semantic to embed now, --fuzzy to skip)"
408 );
409 fuzzy
410 }
411 }
412 #[cfg(not(feature = "_semantic"))]
413 {
414 let _ = (&cli.model, cli.refresh);
415 fuzzy
416 }
417 };
418
419 if matches.is_empty() {
420 crate::status!("no matches for {query:?}");
421 }
422 output.write_matches(&matches)
423}
424
425impl Output {
426 fn write_matches(self, matches: &[Match]) -> Result<()> {
427 #[derive(Serialize)]
428 struct Row<'a> {
429 #[serde(flatten)]
430 record: &'a SchemaRecord,
431 score: f64,
432 }
433 let rows = || {
434 matches.iter().map(|m| Row {
435 record: m.record,
436 score: m.score,
437 })
438 };
439 match self {
440 Output::Json => println!(
441 "{}",
442 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
443 ),
444 Output::Ndjson => {
445 for row in rows() {
446 println!("{}", serde_json::to_string(&row)?);
447 }
448 }
449 Output::Text => print_text(matches),
450 }
451 Ok(())
452 }
453}
454
455fn print_text(matches: &[Match]) {
456 let width = matches
457 .iter()
458 .map(|m| display_path(m.record).len())
459 .max()
460 .unwrap_or(0)
461 .min(48);
462
463 for m in matches {
464 let r = m.record;
465 let path = display_path(r);
466 let ret = r
467 .type_ref
468 .as_deref()
469 .map(|t| format!(" -> {t}"))
470 .unwrap_or_default();
471 let dep = if r.deprecated.is_some() {
472 " (deprecated)"
473 } else {
474 ""
475 };
476 println!("{path:<width$}{ret} [{kind}]{dep}", kind = r.kind.as_str());
477 }
478}
479
480fn run_resolve(
482 query: &str,
483 source: &str,
484 records: &[SchemaRecord],
485 kind: Option<Kind>,
486 code: Option<&str>,
487 limit: usize,
488 output: Output,
489) -> Result<()> {
490 if code.is_none() {
491 crate::status!("no --code given; resolving against rq's index for the current directory");
492 }
493 let Some(top) = search::search(query, records, kind, 1).into_iter().next() else {
494 anyhow::bail!("no schema entity matches {query:?} to resolve");
495 };
496 crate::status!("resolving {} …", top.record.path);
497 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
499 .then(|| std::path::Path::new(source))
500 .filter(|p| p.exists());
501 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
502
503 match output {
504 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
505 Output::Ndjson => {
506 for h in &hits {
507 println!("{}", serde_json::to_string(h)?);
508 }
509 }
510 Output::Text => {
511 if hits.is_empty() {
512 crate::status!(
513 "no code definition found for {} (tried graphql-ruby rq candidates)",
514 top.record.path
515 );
516 }
517 for h in &hits {
518 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
519 }
520 }
521 }
522 Ok(())
523}
524
525fn display_path(r: &SchemaRecord) -> String {
527 if r.args.is_empty() {
528 r.path.clone()
529 } else {
530 format!("{}({})", r.path, r.args.join(", "))
531 }
532}