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 'User.*' wildcard — list a type's fields (quote it)
23 gqls 'User.{first,last}Name' also ? for one char, {a,b} to alternate
24 gqls --returns Company -k query find fields by return type, not name
25 gqls repo schema.json search a local introspection dump
26 gqls repo https://api/graphql introspect a live endpoint
27 gqls 'cancel a subscription' rank by meaning (fuzzy + semantic, auto)
28 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
29 gqls user schema.graphql -j JSON output (-J for ndjson)
30";
31
32#[cfg(not(feature = "_semantic"))]
33const EXAMPLES: &str = "\
34EXAMPLES:
35 gqls user schema.graphql fuzzy search an SDL file
36 gqls createUser -k mutation restrict to a kind (schema auto-discovered)
37 gqls User.email qualified Type.field query
38 gqls 'User.*' wildcard — list a type's fields (quote it)
39 gqls 'User.{first,last}Name' also ? for one char, {a,b} to alternate
40 gqls --returns Company -k query find fields by return type, not name
41 gqls repo schema.json search a local introspection dump
42 gqls repo https://api/graphql introspect a live endpoint
43 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
44 gqls user schema.graphql -j JSON output (-J for ndjson)
45
46Semantic search (--semantic, rank by meaning) is not compiled into this build. Enable it:
47 cargo install gqls-cli --features semantic
48 brew install dpep/tools/gqls
49";
50
51#[derive(Parser)]
52#[command(
53 name = "gqls",
54 version,
55 about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
56 long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
57 terminal. The source is an SDL file, a local introspection JSON dump, or a live \
58 http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
59 Fuzzy and semantic results are ranked together by default (--semantic or \
60 --fuzzy forces one); --resolve jumps to the graphql-ruby resolver via rq. All modes \
61 support -j/--json and -J/--ndjson.",
62 after_help = EXAMPLES
63)]
64struct Cli {
65 #[arg(required_unless_present_any = ["clear_cache", "completions", "warm", "returns"])]
70 query: Option<String>,
71
72 source: Option<String>,
76
77 #[arg(short, long)]
79 kind: Option<String>,
80
81 #[arg(long, value_name = "TYPE")]
85 returns: Option<String>,
86
87 #[arg(short, long, default_value_t = 20)]
89 limit: usize,
90
91 #[arg(short, long, conflicts_with = "ndjson")]
93 json: bool,
94
95 #[arg(short = 'J', long)]
97 ndjson: bool,
98
99 #[arg(short = 'D', long)]
102 no_description: bool,
103
104 #[arg(long, hide = HIDE_SEMANTIC)]
107 semantic: bool,
108
109 #[arg(long, conflicts_with = "semantic")]
111 fuzzy: bool,
112
113 #[arg(long, hide = HIDE_SEMANTIC)]
116 model: Option<String>,
117
118 #[arg(long, hide = HIDE_SEMANTIC)]
122 refresh: bool,
123
124 #[arg(long, hide = HIDE_SEMANTIC)]
126 clear_cache: bool,
127
128 #[arg(long, hide = HIDE_SEMANTIC)]
130 warm: bool,
131
132 #[arg(long, value_name = "SHELL")]
134 completions: Option<Shell>,
135
136 #[arg(short = 'R', long)]
139 resolve: bool,
140
141 #[arg(long)]
143 code: Option<String>,
144
145 #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
148 header: Vec<String>,
149
150 #[arg(short, long, conflicts_with = "quiet")]
153 verbose: bool,
154
155 #[arg(short, long)]
157 quiet: bool,
158}
159
160#[derive(Clone, Copy)]
163enum Output {
164 Text { descriptions: bool },
165 Json,
166 Ndjson,
167}
168
169struct Match<'a> {
172 record: &'a SchemaRecord,
173 score: f64,
174}
175
176fn fuzzy_matches<'a>(
180 query: &str,
181 records: &'a [SchemaRecord],
182 kind: Option<Kind>,
183 parent: Option<&str>,
184 returns: Option<&str>,
185) -> (Vec<Match<'a>>, bool) {
186 let hits = search::search(query, records, kind, parent, returns);
187 let named = search::named_hit(query, &hits);
188 let matches = hits
189 .into_iter()
190 .map(|h| Match {
191 record: h.record,
192 score: h.score as f64,
193 })
194 .collect();
195 (matches, named)
196}
197
198#[cfg(feature = "_semantic")]
199fn semantic_matches<'a>(
200 query: &str,
201 records: &'a [SchemaRecord],
202 kind: Option<Kind>,
203 parent: Option<&str>,
204 returns: Option<&str>,
205 cli: &Cli,
206) -> Vec<Match<'a>> {
207 crate::semantic::search(
208 query,
209 records,
210 kind,
211 parent,
212 returns,
213 cli.limit,
214 cli.model.as_deref(),
215 cli.refresh,
216 )
217 .into_iter()
218 .map(|(score, record)| Match { record, score })
219 .collect()
220}
221
222#[cfg(feature = "_semantic")]
227fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
228 use std::collections::HashMap;
229 const K: f64 = 60.0;
230 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
234 for (rank, m) in fuzzy.iter().enumerate() {
235 scored
236 .entry(m.record.path.as_str())
237 .or_insert((0.0, m.record))
238 .0 += 1.0 / (K + rank as f64 + 1.0);
239 }
240 for (rank, m) in semantic.iter().enumerate() {
241 scored
242 .entry(m.record.path.as_str())
243 .or_insert((0.0, m.record))
244 .0 += 0.7 / (K + rank as f64 + 1.0);
245 }
246 let mut merged: Vec<Match> = scored
247 .into_values()
248 .map(|(score, record)| Match { record, score })
249 .collect();
250 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
251 merged.truncate(limit);
252 merged
253}
254
255#[cfg(feature = "_semantic")]
259fn spawn_background_warm(source: &str, headers: &[String]) {
260 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
261 return;
262 }
263 if !claim_warm_lock(source) {
267 return;
268 }
269 if let Ok(exe) = std::env::current_exe() {
270 let mut cmd = std::process::Command::new(exe);
271 cmd.arg("--warm").arg(source);
272 for h in headers {
273 cmd.arg("--header").arg(h);
274 }
275 let _ = cmd
276 .stdin(std::process::Stdio::null())
277 .stdout(std::process::Stdio::null())
278 .stderr(std::process::Stdio::null())
279 .spawn();
280 }
281}
282
283#[cfg(feature = "_semantic")]
288fn claim_warm_lock(source: &str) -> bool {
289 use std::collections::hash_map::DefaultHasher;
290 use std::hash::{Hash, Hasher};
291 use std::time::Duration;
292
293 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
294 let dir = crate::paths::temp_dir();
296 let mut h = DefaultHasher::new();
297 source.hash(&mut h);
298 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
299 if let Ok(meta) = std::fs::metadata(&lock) {
300 if let Ok(modified) = meta.modified() {
301 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
302 return false; }
304 }
305 }
306 let _ = std::fs::create_dir_all(&dir);
307 std::fs::write(&lock, []).is_ok()
308}
309
310fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
312 raw.iter()
313 .map(|h| {
314 let (name, value) = h
315 .split_once(':')
316 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
317 Ok((name.trim().to_string(), value.trim().to_string()))
318 })
319 .collect()
320}
321
322pub fn run() -> Result<()> {
323 let cli = Cli::parse();
324 crate::logging::init(cli.verbose, cli.quiet);
325
326 if let Some(shell) = cli.completions {
327 let mut cmd = Cli::command();
328 let name = cmd.get_name().to_string();
329 generate(shell, &mut cmd, name, &mut std::io::stdout());
330 return Ok(());
331 }
332
333 if cli.clear_cache {
334 let introspect = crate::load::introspect::clear_cache();
335 let records = crate::load::record_cache::clear();
336 #[cfg(feature = "_semantic")]
337 let vectors = crate::semantic::clear_cache();
338 #[cfg(not(feature = "_semantic"))]
339 let vectors = 0;
340 crate::status!("cleared {} cached file(s)", introspect + records + vectors);
341 return Ok(());
342 }
343
344 let output = if cli.json {
345 Output::Json
346 } else if cli.ndjson {
347 Output::Ndjson
348 } else {
349 Output::Text {
350 descriptions: !cli.no_description,
351 }
352 };
353
354 let kind: Option<Kind> = match &cli.kind {
355 Some(s) => Some(s.parse()?),
356 None => None,
357 };
358
359 let positional_is_source = cli.source.is_none()
366 && cli.returns.is_some()
367 && cli.query.as_deref().is_some_and(looks_like_source);
368
369 let source = if let Some(s) = cli.source.clone() {
370 s
371 } else if cli.warm || positional_is_source {
372 match cli.query.clone() {
373 Some(s) => s,
374 None => load::discover()?,
375 }
376 } else {
377 load::discover()?
378 };
379 let load_opts = load::LoadOptions {
380 headers: parse_headers(&cli.header)?,
381 refresh: cli.refresh,
382 };
383 let t_load = std::time::Instant::now();
384 let records = load::load(&source, &load_opts)?;
385 crate::detail!(
386 "loaded {} records in {:.1?}",
387 records.len(),
388 t_load.elapsed()
389 );
390
391 if cli.warm {
394 #[cfg(feature = "_semantic")]
395 {
396 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
397 crate::status!("cached vectors for {n} record(s)");
398 return Ok(());
399 }
400 #[cfg(not(feature = "_semantic"))]
401 {
402 let _ = (&cli.model, cli.refresh);
403 anyhow::bail!("--warm needs a semantic build");
404 }
405 }
406
407 let query = match cli.query.as_deref() {
409 Some(q) if !positional_is_source => q,
411 _ if cli.returns.is_some() => "*",
413 _ => anyhow::bail!("a QUERY is required (see --help)"),
414 };
415
416 let pattern = search::glob::is_pattern(query);
420 if pattern {
421 crate::detail!("wildcard query — enumerating matches for {query:?}");
422 }
423
424 let spaced = (!pattern)
429 .then(|| search::spaced_qualifier(query, &records))
430 .flatten();
431 let loose = spaced.is_some();
432 let query = spaced.as_deref().unwrap_or(query);
433 if loose {
434 crate::detail!("two-word query names a type — searching as {query:?}");
435 }
436
437 let parent = (!pattern)
442 .then(|| search::parent_filter(query, &records))
443 .flatten();
444 if let Some(p) = parent {
445 let (_, qualifier) = search::score::parse_qualified(query);
446 if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
447 crate::detail!("qualifier {p:?} names a type — restricting to its members");
448 } else {
449 crate::status!(
450 "no type named {:?} — using closest match {p:?}",
451 qualifier.unwrap_or_default()
452 );
453 }
454 }
455
456 if cli.resolve {
457 return run_resolve(
458 query,
459 &source,
460 &records,
461 kind,
462 parent,
463 cli.returns.as_deref(),
464 cli.code.as_deref(),
465 cli.limit,
466 output,
467 );
468 }
469
470 let t_rank = std::time::Instant::now();
474 let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
475 let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent, cli.returns.as_deref());
476 let total = fuzzy.len();
477 fuzzy.truncate(cli.limit);
478 (fuzzy, total)
479 } else if cli.semantic {
480 #[cfg(feature = "_semantic")]
481 {
482 if pattern {
483 crate::status!("--semantic ranks by meaning and ignores wildcards in {query:?}");
484 }
485 let matches =
486 semantic_matches(query, &records, kind, parent, cli.returns.as_deref(), &cli);
487 let total = matches.len();
488 (matches, total)
489 }
490 #[cfg(not(feature = "_semantic"))]
491 {
492 let _ = (&cli.model, cli.refresh);
493 anyhow::bail!(
494 "this build has no semantic search — install it with \
495 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
496 );
497 }
498 } else {
499 let (mut fuzzy, named) =
506 fuzzy_matches(query, &records, kind, parent, cli.returns.as_deref());
507 let total = fuzzy.len();
508 fuzzy.truncate(cli.limit);
509 #[cfg(feature = "_semantic")]
510 {
511 let skip = if pattern {
515 Some("wildcard enumeration")
516 } else if named && !loose {
517 Some("strong name match")
518 } else {
519 None
520 };
521 if let Some(why) = skip {
522 crate::detail!("{why} — semantic ranking skipped (--semantic to force)");
523 (fuzzy, total)
524 } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
525 let semantic =
526 semantic_matches(query, &records, kind, parent, cli.returns.as_deref(), &cli);
527 (combine(fuzzy, semantic, cli.limit), total)
528 } else {
529 spawn_background_warm(&source, &cli.header);
530 crate::status!(
531 "building the semantic index in the background; next run ranks by \
532 meaning (--semantic to wait, --fuzzy to skip)"
533 );
534 (fuzzy, total)
535 }
536 }
537 #[cfg(not(feature = "_semantic"))]
538 {
539 let _ = (&cli.model, cli.refresh, named, loose);
540 (fuzzy, total)
541 }
542 };
543
544 crate::detail!("ranked in {:.1?}", t_rank.elapsed());
545
546 if matches.is_empty() {
547 crate::status!("no matches for {query:?}");
548 }
549 output.write_matches(&matches)?;
550 if total > matches.len() {
551 crate::detail!(
552 "{total} matches; showing top {} (-l to adjust)",
553 matches.len()
554 );
555 }
556 Ok(())
557}
558
559impl Output {
560 fn write_matches(self, matches: &[Match]) -> Result<()> {
561 #[derive(Serialize)]
562 struct Row<'a> {
563 #[serde(flatten)]
564 record: &'a SchemaRecord,
565 score: f64,
566 }
567 let rows = || {
568 matches.iter().map(|m| Row {
569 record: m.record,
570 score: m.score,
571 })
572 };
573 match self {
574 Output::Json => println!(
575 "{}",
576 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
577 ),
578 Output::Ndjson => {
579 for row in rows() {
580 println!("{}", serde_json::to_string(&row)?);
581 }
582 }
583 Output::Text { descriptions } => print_text(matches, descriptions),
584 }
585 Ok(())
586 }
587}
588
589const DESCRIPTION_WIDTH: usize = 72;
593
594fn print_text(matches: &[Match], descriptions: bool) {
595 let width = matches
596 .iter()
597 .map(|m| display_path(m.record).len())
598 .max()
599 .unwrap_or(0)
600 .min(48);
601
602 for m in matches {
603 let r = m.record;
604 let path = display_path(r);
605 let ret = r
606 .type_ref
607 .as_deref()
608 .map(|t| format!(" -> {t}"))
609 .unwrap_or_default();
610 let dep = if r.deprecated.is_some() {
611 " (deprecated)"
612 } else {
613 ""
614 };
615 let desc = if descriptions {
616 r.description
617 .as_deref()
618 .map(summarize)
619 .filter(|d| !d.is_empty())
620 .map(|d| format!(" — {d}"))
621 .unwrap_or_default()
622 } else {
623 String::new()
624 };
625 println!(
626 "{path:<width$}{ret} [{kind}]{dep}{desc}",
627 kind = r.kind.as_str()
628 );
629 }
630}
631
632fn summarize(description: &str) -> String {
635 let mut out = String::new();
636 for (i, word) in description.split_whitespace().enumerate() {
637 if i > 0 {
638 out.push(' ');
639 }
640 out.push_str(word);
641 if out.chars().count() > DESCRIPTION_WIDTH {
642 let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
643 return format!("{}…", kept.trim_end());
644 }
645 }
646 out
647}
648
649#[allow(clippy::too_many_arguments)]
651fn run_resolve(
652 query: &str,
653 source: &str,
654 records: &[SchemaRecord],
655 kind: Option<Kind>,
656 parent: Option<&str>,
657 returns: Option<&str>,
658 code: Option<&str>,
659 limit: usize,
660 output: Output,
661) -> Result<()> {
662 if code.is_none() {
663 crate::status!("searching code in the current directory (--code to search elsewhere)");
664 }
665 let Some(top) = search::search(query, records, kind, parent, returns)
666 .into_iter()
667 .next()
668 else {
669 anyhow::bail!("no schema entity matches {query:?} to resolve");
670 };
671 crate::status!("resolving {} …", top.record.path);
672 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
674 .then(|| std::path::Path::new(source))
675 .filter(|p| p.exists());
676 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
677
678 match output {
679 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
680 Output::Ndjson => {
681 for h in &hits {
682 println!("{}", serde_json::to_string(h)?);
683 }
684 }
685 Output::Text { .. } => {
686 if hits.is_empty() {
687 crate::status!(
688 "no code definition found for {} (-v shows what was tried)",
689 top.record.path
690 );
691 }
692 for h in &hits {
693 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
694 }
695 }
696 }
697 Ok(())
698}
699
700fn looks_like_source(arg: &str) -> bool {
705 arg.starts_with("http://")
706 || arg.starts_with("https://")
707 || [".graphql", ".graphqls", ".gql", ".json"]
708 .iter()
709 .any(|ext| arg.to_ascii_lowercase().ends_with(ext))
710}
711
712fn display_path(r: &SchemaRecord) -> String {
714 if r.args.is_empty() {
715 r.path.clone()
716 } else {
717 format!("{}({})", r.path, r.args.join(", "))
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use super::{looks_like_source, summarize, DESCRIPTION_WIDTH};
724
725 #[test]
726 fn recognizes_schema_sources_but_not_queries() {
727 assert!(looks_like_source("schema.graphql"));
728 assert!(looks_like_source("a/b/Schema.GraphQLS"));
729 assert!(looks_like_source("dump.json"));
730 assert!(looks_like_source("https://api.example.com/graphql"));
731 assert!(!looks_like_source("User.email"));
733 assert!(!looks_like_source("Company"));
734 assert!(!looks_like_source("User.*"));
735 }
736
737 #[test]
738 fn collapses_block_descriptions_to_one_line() {
739 assert_eq!(summarize(" Look up\n a user.\n"), "Look up a user.");
740 assert_eq!(summarize(" "), "");
741 }
742
743 #[test]
744 fn elides_past_the_width() {
745 let long = "word ".repeat(60);
746 let out = summarize(&long);
747 assert!(out.ends_with('…'), "{out}");
748 assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
749 }
750
751 #[test]
752 fn keeps_a_description_that_fits() {
753 let s = "An account.";
754 assert_eq!(summarize(s), s);
755 }
756}