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(short = 'D', long)]
88 no_description: bool,
89
90 #[arg(long, hide = HIDE_SEMANTIC)]
93 semantic: bool,
94
95 #[arg(long, conflicts_with = "semantic")]
97 fuzzy: bool,
98
99 #[arg(long, hide = HIDE_SEMANTIC)]
102 model: Option<String>,
103
104 #[arg(long, hide = HIDE_SEMANTIC)]
108 refresh: bool,
109
110 #[arg(long, hide = HIDE_SEMANTIC)]
112 clear_cache: bool,
113
114 #[arg(long, hide = HIDE_SEMANTIC)]
116 warm: bool,
117
118 #[arg(long, value_name = "SHELL")]
120 completions: Option<Shell>,
121
122 #[arg(short = 'R', long)]
125 resolve: bool,
126
127 #[arg(long)]
129 code: Option<String>,
130
131 #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
134 header: Vec<String>,
135
136 #[arg(short, long, conflicts_with = "quiet")]
139 verbose: bool,
140
141 #[arg(short, long)]
143 quiet: bool,
144}
145
146#[derive(Clone, Copy)]
149enum Output {
150 Text { descriptions: bool },
151 Json,
152 Ndjson,
153}
154
155struct Match<'a> {
158 record: &'a SchemaRecord,
159 score: f64,
160}
161
162fn fuzzy_matches<'a>(
166 query: &str,
167 records: &'a [SchemaRecord],
168 kind: Option<Kind>,
169 parent: Option<&str>,
170) -> (Vec<Match<'a>>, bool) {
171 let hits = search::search(query, records, kind, parent);
172 let named = search::named_hit(query, &hits);
173 let matches = hits
174 .into_iter()
175 .map(|h| Match {
176 record: h.record,
177 score: h.score as f64,
178 })
179 .collect();
180 (matches, named)
181}
182
183#[cfg(feature = "_semantic")]
184fn semantic_matches<'a>(
185 query: &str,
186 records: &'a [SchemaRecord],
187 kind: Option<Kind>,
188 parent: Option<&str>,
189 cli: &Cli,
190) -> Vec<Match<'a>> {
191 crate::semantic::search(
192 query,
193 records,
194 kind,
195 parent,
196 cli.limit,
197 cli.model.as_deref(),
198 cli.refresh,
199 )
200 .into_iter()
201 .map(|(score, record)| Match { record, score })
202 .collect()
203}
204
205#[cfg(feature = "_semantic")]
210fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
211 use std::collections::HashMap;
212 const K: f64 = 60.0;
213 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
217 for (rank, m) in fuzzy.iter().enumerate() {
218 scored
219 .entry(m.record.path.as_str())
220 .or_insert((0.0, m.record))
221 .0 += 1.0 / (K + rank as f64 + 1.0);
222 }
223 for (rank, m) in semantic.iter().enumerate() {
224 scored
225 .entry(m.record.path.as_str())
226 .or_insert((0.0, m.record))
227 .0 += 0.7 / (K + rank as f64 + 1.0);
228 }
229 let mut merged: Vec<Match> = scored
230 .into_values()
231 .map(|(score, record)| Match { record, score })
232 .collect();
233 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
234 merged.truncate(limit);
235 merged
236}
237
238#[cfg(feature = "_semantic")]
242fn spawn_background_warm(source: &str, headers: &[String]) {
243 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
244 return;
245 }
246 if !claim_warm_lock(source) {
250 return;
251 }
252 if let Ok(exe) = std::env::current_exe() {
253 let mut cmd = std::process::Command::new(exe);
254 cmd.arg("--warm").arg(source);
255 for h in headers {
256 cmd.arg("--header").arg(h);
257 }
258 let _ = cmd
259 .stdin(std::process::Stdio::null())
260 .stdout(std::process::Stdio::null())
261 .stderr(std::process::Stdio::null())
262 .spawn();
263 }
264}
265
266#[cfg(feature = "_semantic")]
271fn claim_warm_lock(source: &str) -> bool {
272 use std::collections::hash_map::DefaultHasher;
273 use std::hash::{Hash, Hasher};
274 use std::time::Duration;
275
276 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
277 let dir = crate::paths::temp_dir();
279 let mut h = DefaultHasher::new();
280 source.hash(&mut h);
281 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
282 if let Ok(meta) = std::fs::metadata(&lock) {
283 if let Ok(modified) = meta.modified() {
284 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
285 return false; }
287 }
288 }
289 let _ = std::fs::create_dir_all(&dir);
290 std::fs::write(&lock, []).is_ok()
291}
292
293fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
295 raw.iter()
296 .map(|h| {
297 let (name, value) = h
298 .split_once(':')
299 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
300 Ok((name.trim().to_string(), value.trim().to_string()))
301 })
302 .collect()
303}
304
305pub fn run() -> Result<()> {
306 let cli = Cli::parse();
307 crate::logging::init(cli.verbose, cli.quiet);
308
309 if let Some(shell) = cli.completions {
310 let mut cmd = Cli::command();
311 let name = cmd.get_name().to_string();
312 generate(shell, &mut cmd, name, &mut std::io::stdout());
313 return Ok(());
314 }
315
316 if cli.clear_cache {
317 let introspect = crate::load::introspect::clear_cache();
318 let records = crate::load::record_cache::clear();
319 #[cfg(feature = "_semantic")]
320 let vectors = crate::semantic::clear_cache();
321 #[cfg(not(feature = "_semantic"))]
322 let vectors = 0;
323 crate::status!("cleared {} cached file(s)", introspect + records + vectors);
324 return Ok(());
325 }
326
327 let output = if cli.json {
328 Output::Json
329 } else if cli.ndjson {
330 Output::Ndjson
331 } else {
332 Output::Text {
333 descriptions: !cli.no_description,
334 }
335 };
336
337 let kind: Option<Kind> = match &cli.kind {
338 Some(s) => Some(s.parse()?),
339 None => None,
340 };
341
342 let source = if let Some(s) = cli.source.clone() {
346 s
347 } else if cli.warm {
348 match cli.query.clone() {
349 Some(s) => s,
350 None => load::discover()?,
351 }
352 } else {
353 load::discover()?
354 };
355 let load_opts = load::LoadOptions {
356 headers: parse_headers(&cli.header)?,
357 refresh: cli.refresh,
358 };
359 let t_load = std::time::Instant::now();
360 let records = load::load(&source, &load_opts)?;
361 crate::detail!(
362 "loaded {} records in {:.1?}",
363 records.len(),
364 t_load.elapsed()
365 );
366
367 if cli.warm {
370 #[cfg(feature = "_semantic")]
371 {
372 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
373 crate::status!("cached vectors for {n} record(s)");
374 return Ok(());
375 }
376 #[cfg(not(feature = "_semantic"))]
377 {
378 let _ = (&cli.model, cli.refresh);
379 anyhow::bail!("--warm needs a semantic build");
380 }
381 }
382
383 let query = cli
385 .query
386 .as_deref()
387 .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
388
389 let spaced = search::spaced_qualifier(query, &records);
394 let loose = spaced.is_some();
395 let query = spaced.as_deref().unwrap_or(query);
396 if loose {
397 crate::detail!("two-word query names a type — searching as {query:?}");
398 }
399
400 let parent = search::parent_filter(query, &records);
405 if let Some(p) = parent {
406 let (_, qualifier) = search::score::parse_qualified(query);
407 if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
408 crate::detail!("qualifier {p:?} names a type — restricting to its members");
409 } else {
410 crate::status!(
411 "no type named {:?} — using closest match {p:?}",
412 qualifier.unwrap_or_default()
413 );
414 }
415 }
416
417 if cli.resolve {
418 return run_resolve(
419 query,
420 &source,
421 &records,
422 kind,
423 parent,
424 cli.code.as_deref(),
425 cli.limit,
426 output,
427 );
428 }
429
430 let t_rank = std::time::Instant::now();
434 let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
435 let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent);
436 let total = fuzzy.len();
437 fuzzy.truncate(cli.limit);
438 (fuzzy, total)
439 } else if cli.semantic {
440 #[cfg(feature = "_semantic")]
441 {
442 let matches = semantic_matches(query, &records, kind, parent, &cli);
443 let total = matches.len();
444 (matches, total)
445 }
446 #[cfg(not(feature = "_semantic"))]
447 {
448 let _ = (&cli.model, cli.refresh);
449 anyhow::bail!(
450 "this build has no semantic search — install it with \
451 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
452 );
453 }
454 } else {
455 let (mut fuzzy, named) = fuzzy_matches(query, &records, kind, parent);
462 let total = fuzzy.len();
463 fuzzy.truncate(cli.limit);
464 #[cfg(feature = "_semantic")]
465 {
466 if named && !loose {
467 crate::detail!(
468 "strong name match — semantic ranking skipped (--semantic to force)"
469 );
470 (fuzzy, total)
471 } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
472 let semantic = semantic_matches(query, &records, kind, parent, &cli);
473 (combine(fuzzy, semantic, cli.limit), total)
474 } else {
475 spawn_background_warm(&source, &cli.header);
476 crate::status!(
477 "building the semantic index in the background; next run ranks by \
478 meaning (--semantic to wait, --fuzzy to skip)"
479 );
480 (fuzzy, total)
481 }
482 }
483 #[cfg(not(feature = "_semantic"))]
484 {
485 let _ = (&cli.model, cli.refresh, named, loose);
486 (fuzzy, total)
487 }
488 };
489
490 crate::detail!("ranked in {:.1?}", t_rank.elapsed());
491
492 if matches.is_empty() {
493 crate::status!("no matches for {query:?}");
494 }
495 output.write_matches(&matches)?;
496 if total > matches.len() {
497 crate::detail!(
498 "{total} matches; showing top {} (-l to adjust)",
499 matches.len()
500 );
501 }
502 Ok(())
503}
504
505impl Output {
506 fn write_matches(self, matches: &[Match]) -> Result<()> {
507 #[derive(Serialize)]
508 struct Row<'a> {
509 #[serde(flatten)]
510 record: &'a SchemaRecord,
511 score: f64,
512 }
513 let rows = || {
514 matches.iter().map(|m| Row {
515 record: m.record,
516 score: m.score,
517 })
518 };
519 match self {
520 Output::Json => println!(
521 "{}",
522 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
523 ),
524 Output::Ndjson => {
525 for row in rows() {
526 println!("{}", serde_json::to_string(&row)?);
527 }
528 }
529 Output::Text { descriptions } => print_text(matches, descriptions),
530 }
531 Ok(())
532 }
533}
534
535const DESCRIPTION_WIDTH: usize = 72;
539
540fn print_text(matches: &[Match], descriptions: bool) {
541 let width = matches
542 .iter()
543 .map(|m| display_path(m.record).len())
544 .max()
545 .unwrap_or(0)
546 .min(48);
547
548 for m in matches {
549 let r = m.record;
550 let path = display_path(r);
551 let ret = r
552 .type_ref
553 .as_deref()
554 .map(|t| format!(" -> {t}"))
555 .unwrap_or_default();
556 let dep = if r.deprecated.is_some() {
557 " (deprecated)"
558 } else {
559 ""
560 };
561 let desc = if descriptions {
562 r.description
563 .as_deref()
564 .map(summarize)
565 .filter(|d| !d.is_empty())
566 .map(|d| format!(" — {d}"))
567 .unwrap_or_default()
568 } else {
569 String::new()
570 };
571 println!(
572 "{path:<width$}{ret} [{kind}]{dep}{desc}",
573 kind = r.kind.as_str()
574 );
575 }
576}
577
578fn summarize(description: &str) -> String {
581 let mut out = String::new();
582 for (i, word) in description.split_whitespace().enumerate() {
583 if i > 0 {
584 out.push(' ');
585 }
586 out.push_str(word);
587 if out.chars().count() > DESCRIPTION_WIDTH {
588 let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
589 return format!("{}…", kept.trim_end());
590 }
591 }
592 out
593}
594
595#[allow(clippy::too_many_arguments)]
597fn run_resolve(
598 query: &str,
599 source: &str,
600 records: &[SchemaRecord],
601 kind: Option<Kind>,
602 parent: Option<&str>,
603 code: Option<&str>,
604 limit: usize,
605 output: Output,
606) -> Result<()> {
607 if code.is_none() {
608 crate::status!("searching code in the current directory (--code to search elsewhere)");
609 }
610 let Some(top) = search::search(query, records, kind, parent)
611 .into_iter()
612 .next()
613 else {
614 anyhow::bail!("no schema entity matches {query:?} to resolve");
615 };
616 crate::status!("resolving {} …", top.record.path);
617 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
619 .then(|| std::path::Path::new(source))
620 .filter(|p| p.exists());
621 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
622
623 match output {
624 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
625 Output::Ndjson => {
626 for h in &hits {
627 println!("{}", serde_json::to_string(h)?);
628 }
629 }
630 Output::Text { .. } => {
631 if hits.is_empty() {
632 crate::status!(
633 "no code definition found for {} (-v shows what was tried)",
634 top.record.path
635 );
636 }
637 for h in &hits {
638 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
639 }
640 }
641 }
642 Ok(())
643}
644
645fn display_path(r: &SchemaRecord) -> String {
647 if r.args.is_empty() {
648 r.path.clone()
649 } else {
650 format!("{}({})", r.path, r.args.join(", "))
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::{summarize, DESCRIPTION_WIDTH};
657
658 #[test]
659 fn collapses_block_descriptions_to_one_line() {
660 assert_eq!(summarize(" Look up\n a user.\n"), "Look up a user.");
661 assert_eq!(summarize(" "), "");
662 }
663
664 #[test]
665 fn elides_past_the_width() {
666 let long = "word ".repeat(60);
667 let out = summarize(&long);
668 assert!(out.ends_with('…'), "{out}");
669 assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
670 }
671
672 #[test]
673 fn keeps_a_description_that_fits() {
674 let s = "An account.";
675 assert_eq!(summarize(s), s);
676 }
677}