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. list a type's fields (or 'User.*')
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. list a type's fields (or 'User.*')
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"])]
71 query: Option<String>,
72
73 source: Option<String>,
77
78 #[arg(short, long)]
80 kind: Option<String>,
81
82 #[arg(long, value_name = "TYPE")]
86 returns: Option<String>,
87
88 #[arg(short, long, default_value_t = 20)]
90 limit: usize,
91
92 #[arg(short, long, conflicts_with = "ndjson")]
94 json: bool,
95
96 #[arg(short = 'J', long)]
98 ndjson: bool,
99
100 #[arg(short = 'D', long)]
103 no_description: bool,
104
105 #[arg(long, hide = HIDE_SEMANTIC)]
108 semantic: bool,
109
110 #[arg(long, conflicts_with = "semantic")]
112 fuzzy: bool,
113
114 #[arg(long, hide = HIDE_SEMANTIC)]
117 model: Option<String>,
118
119 #[arg(long, hide = HIDE_SEMANTIC)]
123 refresh: bool,
124
125 #[arg(long, hide = HIDE_SEMANTIC)]
127 clear_cache: bool,
128
129 #[arg(long, hide = HIDE_SEMANTIC)]
131 warm: bool,
132
133 #[arg(long, value_name = "SHELL")]
135 completions: Option<Shell>,
136
137 #[arg(short = 'R', long)]
140 resolve: bool,
141
142 #[arg(long)]
144 code: Option<String>,
145
146 #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
149 header: Vec<String>,
150
151 #[arg(short, long, conflicts_with = "quiet")]
154 verbose: bool,
155
156 #[arg(short, long)]
158 quiet: bool,
159}
160
161#[derive(Clone, Copy)]
164enum Output {
165 Text { descriptions: bool },
166 Json,
167 Ndjson,
168}
169
170struct Match<'a> {
173 record: &'a SchemaRecord,
174 score: f64,
175}
176
177fn fuzzy_matches<'a>(
181 query: &str,
182 records: &'a [SchemaRecord],
183 filters: search::Filters<'_>,
184) -> (Vec<Match<'a>>, bool) {
185 let hits = search::search(query, records, filters);
186 let named = search::named_hit(query, &hits);
187 let matches = hits
188 .into_iter()
189 .map(|h| Match {
190 record: h.record,
191 score: h.score as f64,
192 })
193 .collect();
194 (matches, named)
195}
196
197#[cfg(feature = "_semantic")]
198fn semantic_matches<'a>(
199 query: &str,
200 records: &'a [SchemaRecord],
201 filters: search::Filters<'_>,
202 cli: &Cli,
203) -> Vec<Match<'a>> {
204 crate::semantic::search(
205 query,
206 records,
207 filters,
208 cli.limit,
209 cli.model.as_deref(),
210 cli.refresh,
211 )
212 .into_iter()
213 .map(|(score, record)| Match { record, score })
214 .collect()
215}
216
217#[cfg(feature = "_semantic")]
222fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
223 use std::collections::HashMap;
224 const K: f64 = 60.0;
225 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
229 for (rank, m) in fuzzy.iter().enumerate() {
230 scored
231 .entry(m.record.path.as_str())
232 .or_insert((0.0, m.record))
233 .0 += 1.0 / (K + rank as f64 + 1.0);
234 }
235 for (rank, m) in semantic.iter().enumerate() {
236 scored
237 .entry(m.record.path.as_str())
238 .or_insert((0.0, m.record))
239 .0 += 0.7 / (K + rank as f64 + 1.0);
240 }
241 let mut merged: Vec<Match> = scored
242 .into_values()
243 .map(|(score, record)| Match { record, score })
244 .collect();
245 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
246 merged.truncate(limit);
247 merged
248}
249
250#[cfg(feature = "_semantic")]
254fn spawn_background_warm(source: &str, headers: &[String]) {
255 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
256 return;
257 }
258 if !claim_warm_lock(source) {
262 return;
263 }
264 if let Ok(exe) = std::env::current_exe() {
265 let mut cmd = std::process::Command::new(exe);
266 cmd.arg("--warm").arg(source);
267 for h in headers {
268 cmd.arg("--header").arg(h);
269 }
270 let _ = cmd
271 .stdin(std::process::Stdio::null())
272 .stdout(std::process::Stdio::null())
273 .stderr(std::process::Stdio::null())
274 .spawn();
275 }
276}
277
278#[cfg(feature = "_semantic")]
283fn claim_warm_lock(source: &str) -> bool {
284 use std::collections::hash_map::DefaultHasher;
285 use std::hash::{Hash, Hasher};
286 use std::time::Duration;
287
288 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
289 let dir = crate::paths::temp_dir();
291 let mut h = DefaultHasher::new();
292 source.hash(&mut h);
293 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
294 if let Ok(meta) = std::fs::metadata(&lock) {
295 if let Ok(modified) = meta.modified() {
296 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
297 return false; }
299 }
300 }
301 let _ = std::fs::create_dir_all(&dir);
302 std::fs::write(&lock, []).is_ok()
303}
304
305fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
307 raw.iter()
308 .map(|h| {
309 let (name, value) = h
310 .split_once(':')
311 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
312 Ok((name.trim().to_string(), value.trim().to_string()))
313 })
314 .collect()
315}
316
317pub fn run() -> Result<()> {
318 let cli = Cli::parse();
319 crate::logging::init(cli.verbose, cli.quiet);
320
321 if let Some(shell) = cli.completions {
322 let mut cmd = Cli::command();
323 let name = cmd.get_name().to_string();
324 generate(shell, &mut cmd, name, &mut std::io::stdout());
325 return Ok(());
326 }
327
328 if cli.clear_cache {
329 let introspect = crate::load::introspect::clear_cache();
330 let records = crate::load::record_cache::clear();
331 #[cfg(feature = "_semantic")]
332 let vectors = crate::semantic::clear_cache();
333 #[cfg(not(feature = "_semantic"))]
334 let vectors = 0;
335 crate::status!("cleared {} cached file(s)", introspect + records + vectors);
336 return Ok(());
337 }
338
339 let output = if cli.json {
340 Output::Json
341 } else if cli.ndjson {
342 Output::Ndjson
343 } else {
344 Output::Text {
345 descriptions: !cli.no_description,
346 }
347 };
348
349 let kind: Option<Kind> = match &cli.kind {
350 Some(s) => Some(s.parse()?),
351 None => None,
352 };
353
354 let positional_is_source = cli.source.is_none()
361 && cli.returns.is_some()
362 && cli.query.as_deref().is_some_and(looks_like_source);
363
364 let source = if let Some(s) = cli.source.clone() {
365 s
366 } else if cli.warm || positional_is_source {
367 match cli.query.clone() {
368 Some(s) => s,
369 None => load::discover()?,
370 }
371 } else {
372 load::discover()?
373 };
374 let load_opts = load::LoadOptions {
375 headers: parse_headers(&cli.header)?,
376 refresh: cli.refresh,
377 };
378 let t_load = std::time::Instant::now();
379 let records = load::load(&source, &load_opts)?;
380 crate::detail!(
381 "loaded {} records in {:.1?}",
382 records.len(),
383 t_load.elapsed()
384 );
385
386 if cli.warm {
389 #[cfg(feature = "_semantic")]
390 {
391 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
392 crate::status!("cached vectors for {n} record(s)");
393 return Ok(());
394 }
395 #[cfg(not(feature = "_semantic"))]
396 {
397 let _ = (&cli.model, cli.refresh);
398 anyhow::bail!("--warm needs a semantic build");
399 }
400 }
401
402 let query = match cli.query.as_deref() {
404 Some(q) if !positional_is_source => q,
406 _ if cli.returns.is_some() => "*",
408 _ => anyhow::bail!("a QUERY is required (see --help)"),
409 };
410
411 let pattern = search::glob::is_pattern(query);
415 if pattern {
416 crate::detail!("wildcard query — enumerating matches for {query:?}");
417 }
418
419 let spaced = (!pattern)
424 .then(|| search::spaced_qualifier(query, &records))
425 .flatten();
426 let loose = spaced.is_some();
427 let query = spaced.as_deref().unwrap_or(query);
428 if loose {
429 crate::detail!("two-word query names a type — searching as {query:?}");
430 }
431
432 let parent = (!pattern)
437 .then(|| search::parent_filter(query, &records))
438 .flatten();
439 if let Some(p) = parent {
440 let (_, qualifier) = search::score::parse_qualified(query);
441 if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
442 crate::detail!("qualifier {p:?} names a type — restricting to its members");
443 } else {
444 crate::status!(
445 "no type named {:?} — using closest match {p:?}",
446 qualifier.unwrap_or_default()
447 );
448 }
449 }
450
451 let filters = search::Filters {
452 kind,
453 parent,
454 returns: cli.returns.as_deref(),
455 };
456
457 if cli.resolve {
458 return run_resolve(
459 query,
460 &source,
461 &records,
462 filters,
463 cli.code.as_deref(),
464 cli.limit,
465 output,
466 );
467 }
468
469 let t_rank = std::time::Instant::now();
473 let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
474 let (mut fuzzy, _) = fuzzy_matches(query, &records, filters);
475 let total = fuzzy.len();
476 fuzzy.truncate(cli.limit);
477 (fuzzy, total)
478 } else if cli.semantic {
479 #[cfg(feature = "_semantic")]
480 {
481 if pattern {
482 crate::status!("--semantic ranks by meaning and ignores wildcards in {query:?}");
483 }
484 let matches = semantic_matches(query, &records, filters, &cli);
485 let total = matches.len();
486 (matches, total)
487 }
488 #[cfg(not(feature = "_semantic"))]
489 {
490 let _ = (&cli.model, cli.refresh);
491 anyhow::bail!(
492 "this build has no semantic search — install it with \
493 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
494 );
495 }
496 } else {
497 let (mut fuzzy, named) = fuzzy_matches(query, &records, filters);
504 let total = fuzzy.len();
505 fuzzy.truncate(cli.limit);
506 #[cfg(feature = "_semantic")]
507 {
508 let skip = if pattern {
512 Some("wildcard enumeration")
513 } else if named && !loose {
514 Some("strong name match")
515 } else {
516 None
517 };
518 if let Some(why) = skip {
519 crate::detail!("{why} — semantic ranking skipped (--semantic to force)");
520 (fuzzy, total)
521 } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
522 let semantic = semantic_matches(query, &records, filters, &cli);
523 (combine(fuzzy, semantic, cli.limit), total)
524 } else {
525 spawn_background_warm(&source, &cli.header);
526 crate::status!(
527 "building the semantic index in the background; next run ranks by \
528 meaning (--semantic to wait, --fuzzy to skip)"
529 );
530 (fuzzy, total)
531 }
532 }
533 #[cfg(not(feature = "_semantic"))]
534 {
535 let _ = (&cli.model, cli.refresh, named, loose);
536 (fuzzy, total)
537 }
538 };
539
540 crate::detail!("ranked in {:.1?}", t_rank.elapsed());
541
542 if matches.is_empty() {
543 crate::status!("no matches for {query:?}");
544 }
545 output.write_matches(&matches)?;
546 if total > matches.len() {
547 crate::detail!(
548 "{total} matches; showing top {} (-l to adjust)",
549 matches.len()
550 );
551 }
552 Ok(())
553}
554
555impl Output {
556 fn write_matches(self, matches: &[Match]) -> Result<()> {
557 #[derive(Serialize)]
558 struct Row<'a> {
559 #[serde(flatten)]
560 record: &'a SchemaRecord,
561 score: f64,
562 }
563 let rows = || {
564 matches.iter().map(|m| Row {
565 record: m.record,
566 score: m.score,
567 })
568 };
569 match self {
570 Output::Json => println!(
571 "{}",
572 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
573 ),
574 Output::Ndjson => {
575 for row in rows() {
576 println!("{}", serde_json::to_string(&row)?);
577 }
578 }
579 Output::Text { descriptions } => print_text(matches, descriptions),
580 }
581 Ok(())
582 }
583}
584
585const DESCRIPTION_WIDTH: usize = 72;
589
590fn print_text(matches: &[Match], descriptions: bool) {
591 let width = matches
592 .iter()
593 .map(|m| display_path(m.record).len())
594 .max()
595 .unwrap_or(0)
596 .min(48);
597
598 for m in matches {
599 let r = m.record;
600 let path = display_path(r);
601 let ret = r
602 .type_ref
603 .as_deref()
604 .map(|t| format!(" -> {t}"))
605 .unwrap_or_default();
606 let dep = if r.deprecated.is_some() {
607 " (deprecated)"
608 } else {
609 ""
610 };
611 let desc = if descriptions {
612 r.description
613 .as_deref()
614 .map(summarize)
615 .filter(|d| !d.is_empty())
616 .map(|d| format!(" — {d}"))
617 .unwrap_or_default()
618 } else {
619 String::new()
620 };
621 println!(
622 "{path:<width$}{ret} [{kind}]{dep}{desc}",
623 kind = r.kind.as_str()
624 );
625 }
626}
627
628fn summarize(description: &str) -> String {
631 let mut out = String::new();
632 for (i, word) in description.split_whitespace().enumerate() {
633 if i > 0 {
634 out.push(' ');
635 }
636 out.push_str(word);
637 if out.chars().count() > DESCRIPTION_WIDTH {
638 let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
639 return format!("{}…", kept.trim_end());
640 }
641 }
642 out
643}
644
645fn run_resolve(
647 query: &str,
648 source: &str,
649 records: &[SchemaRecord],
650 filters: search::Filters<'_>,
651 code: Option<&str>,
652 limit: usize,
653 output: Output,
654) -> Result<()> {
655 if code.is_none() {
656 crate::status!("searching code in the current directory (--code to search elsewhere)");
657 }
658 let Some(top) = search::search(query, records, filters).into_iter().next() else {
659 anyhow::bail!("no schema entity matches {query:?} to resolve");
660 };
661 crate::status!("resolving {} …", top.record.path);
662 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
664 .then(|| std::path::Path::new(source))
665 .filter(|p| p.exists());
666 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
667
668 match output {
669 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
670 Output::Ndjson => {
671 for h in &hits {
672 println!("{}", serde_json::to_string(h)?);
673 }
674 }
675 Output::Text { .. } => {
676 if hits.is_empty() {
677 crate::status!(
678 "no code definition found for {} (-v shows what was tried)",
679 top.record.path
680 );
681 }
682 for h in &hits {
683 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
684 }
685 }
686 }
687 Ok(())
688}
689
690fn looks_like_source(arg: &str) -> bool {
695 arg.starts_with("http://")
696 || arg.starts_with("https://")
697 || [".graphql", ".graphqls", ".gql", ".json"]
698 .iter()
699 .any(|ext| arg.to_ascii_lowercase().ends_with(ext))
700}
701
702fn display_path(r: &SchemaRecord) -> String {
704 if r.args.is_empty() {
705 r.path.clone()
706 } else {
707 format!("{}({})", r.path, r.args.join(", "))
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::{looks_like_source, summarize, DESCRIPTION_WIDTH};
714
715 #[test]
716 fn recognizes_schema_sources_but_not_queries() {
717 assert!(looks_like_source("schema.graphql"));
718 assert!(looks_like_source("a/b/Schema.GraphQLS"));
719 assert!(looks_like_source("dump.json"));
720 assert!(looks_like_source("https://api.example.com/graphql"));
721 assert!(!looks_like_source("User.email"));
723 assert!(!looks_like_source("Company"));
724 assert!(!looks_like_source("User.*"));
725 }
726
727 #[test]
728 fn collapses_block_descriptions_to_one_line() {
729 assert_eq!(summarize(" Look up\n a user.\n"), "Look up a user.");
730 assert_eq!(summarize(" "), "");
731 }
732
733 #[test]
734 fn elides_past_the_width() {
735 let long = "word ".repeat(60);
736 let out = summarize(&long);
737 assert!(out.ends_with('…'), "{out}");
738 assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
739 }
740
741 #[test]
742 fn keeps_a_description_that_fits() {
743 let s = "An account.";
744 assert_eq!(summarize(s), s);
745 }
746}