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 Mutation.createUser -e draft an operation to paste
29 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
30 gqls user schema.graphql -j JSON output (-J for ndjson)
31";
32
33#[cfg(not(feature = "_semantic"))]
34const EXAMPLES: &str = "\
35EXAMPLES:
36 gqls user schema.graphql fuzzy search an SDL file
37 gqls createUser -k mutation restrict to a kind (schema auto-discovered)
38 gqls User.email qualified Type.field query
39 gqls User. list a type's fields (or 'User.*')
40 gqls 'User.{first,last}Name' also ? for one char, {a,b} to alternate
41 gqls --returns Company -k query find fields by return type, not name
42 gqls repo schema.json search a local introspection dump
43 gqls repo https://api/graphql introspect a live endpoint
44 gqls Mutation.createUser -e draft an operation to paste
45 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
46 gqls user schema.graphql -j JSON output (-J for ndjson)
47
48Semantic search (--semantic, rank by meaning) is not compiled into this build. Enable it:
49 cargo install gqls-cli --features semantic
50 brew install dpep/tools/gqls
51";
52
53#[derive(Parser)]
54#[command(
55 name = "gqls",
56 version,
57 about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
58 long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
59 terminal. The source is an SDL file, a local introspection JSON dump, or a live \
60 http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
61 Fuzzy and semantic results are ranked together by default (--semantic or \
62 --fuzzy forces one); --resolve jumps to the graphql-ruby resolver via rq. All modes \
63 support -j/--json and -J/--ndjson.",
64 after_help = EXAMPLES
65)]
66struct Cli {
67 #[arg(required_unless_present_any = ["clear_cache", "completions", "warm", "returns"])]
73 query: Option<String>,
74
75 source: Option<String>,
79
80 #[arg(short, long)]
82 kind: Option<String>,
83
84 #[arg(long, value_name = "TYPE")]
88 returns: Option<String>,
89
90 #[arg(short, long, default_value_t = 20)]
92 limit: usize,
93
94 #[arg(short, long, conflicts_with = "ndjson")]
96 json: bool,
97
98 #[arg(short = 'J', long)]
100 ndjson: bool,
101
102 #[arg(short = 'D', long)]
105 no_description: bool,
106
107 #[arg(long, hide = HIDE_SEMANTIC)]
110 semantic: bool,
111
112 #[arg(long, conflicts_with = "semantic")]
114 fuzzy: bool,
115
116 #[arg(long, hide = HIDE_SEMANTIC)]
119 model: Option<String>,
120
121 #[arg(long, hide = HIDE_SEMANTIC)]
125 refresh: bool,
126
127 #[arg(long, hide = HIDE_SEMANTIC)]
129 clear_cache: bool,
130
131 #[arg(long, hide = HIDE_SEMANTIC)]
133 warm: bool,
134
135 #[arg(long, value_name = "SHELL")]
137 completions: Option<Shell>,
138
139 #[arg(short = 'e', long, conflicts_with = "resolve")]
142 example: bool,
143
144 #[arg(long, value_name = "N", default_value_t = 1)]
147 depth: usize,
148
149 #[arg(short = 'R', long)]
152 resolve: bool,
153
154 #[arg(long)]
156 code: Option<String>,
157
158 #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
161 header: Vec<String>,
162
163 #[arg(long)]
165 profile: bool,
166
167 #[arg(short, long, conflicts_with = "quiet")]
170 verbose: bool,
171
172 #[arg(short, long)]
174 quiet: bool,
175}
176
177#[derive(Clone, Copy)]
180enum Output {
181 Text { descriptions: bool },
182 Json,
183 Ndjson,
184}
185
186struct Match<'a> {
189 record: &'a SchemaRecord,
190 score: f64,
191}
192
193fn fuzzy_matches<'a>(
197 query: &str,
198 records: &'a [SchemaRecord],
199 filters: search::Filters<'_>,
200) -> (Vec<Match<'a>>, bool) {
201 let mut span = crate::profile::span("fuzzy scan");
202 let hits = search::search(query, records, filters);
203 span.note(|| format!("{} of {} records matched", hits.len(), records.len()));
204 let named = search::named_hit(query, &hits);
205 let matches = hits
206 .into_iter()
207 .map(|h| Match {
208 record: h.record,
209 score: h.score as f64,
210 })
211 .collect();
212 (matches, named)
213}
214
215#[cfg(feature = "_semantic")]
216fn semantic_matches<'a>(
217 query: &str,
218 records: &'a [SchemaRecord],
219 filters: search::Filters<'_>,
220 cli: &Cli,
221) -> Vec<Match<'a>> {
222 crate::semantic::search(
223 query,
224 records,
225 filters,
226 cli.limit,
227 cli.model.as_deref(),
228 cli.refresh,
229 )
230 .into_iter()
231 .map(|(score, record)| Match { record, score })
232 .collect()
233}
234
235#[cfg(feature = "_semantic")]
240fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
241 use std::collections::HashMap;
242 const K: f64 = 60.0;
243 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
247 for (rank, m) in fuzzy.iter().enumerate() {
248 scored
249 .entry(m.record.path.as_str())
250 .or_insert((0.0, m.record))
251 .0 += 1.0 / (K + rank as f64 + 1.0);
252 }
253 for (rank, m) in semantic.iter().enumerate() {
254 scored
255 .entry(m.record.path.as_str())
256 .or_insert((0.0, m.record))
257 .0 += 0.7 / (K + rank as f64 + 1.0);
258 }
259 let mut merged: Vec<Match> = scored
260 .into_values()
261 .map(|(score, record)| Match { record, score })
262 .collect();
263 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
264 merged.truncate(limit);
265 merged
266}
267
268#[cfg(feature = "_semantic")]
272fn spawn_background_warm(source: &str, headers: &[String]) {
273 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
274 return;
275 }
276 if !claim_warm_lock(source) {
280 return;
281 }
282 if let Ok(exe) = std::env::current_exe() {
283 let mut cmd = std::process::Command::new(exe);
284 cmd.arg("--warm").arg(source);
285 for h in headers {
286 cmd.arg("--header").arg(h);
287 }
288 let _ = cmd
289 .stdin(std::process::Stdio::null())
290 .stdout(std::process::Stdio::null())
291 .stderr(std::process::Stdio::null())
292 .spawn();
293 }
294}
295
296#[cfg(feature = "_semantic")]
301fn claim_warm_lock(source: &str) -> bool {
302 use std::collections::hash_map::DefaultHasher;
303 use std::hash::{Hash, Hasher};
304 use std::time::Duration;
305
306 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
307 let dir = crate::paths::temp_dir();
309 let mut h = DefaultHasher::new();
310 source.hash(&mut h);
311 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
312 if let Ok(meta) = std::fs::metadata(&lock) {
313 if let Ok(modified) = meta.modified() {
314 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
315 return false; }
317 }
318 }
319 let _ = std::fs::create_dir_all(&dir);
320 std::fs::write(&lock, []).is_ok()
321}
322
323fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
325 raw.iter()
326 .map(|h| {
327 let (name, value) = h
328 .split_once(':')
329 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
330 Ok((name.trim().to_string(), value.trim().to_string()))
331 })
332 .collect()
333}
334
335pub fn run() -> Result<()> {
336 let started = std::time::Instant::now();
337 let cli = Cli::parse();
338 crate::logging::init(cli.verbose, cli.quiet);
339 if cli.profile {
340 crate::profile::enable();
341 }
342
343 if let Some(shell) = cli.completions {
344 let mut cmd = Cli::command();
345 let name = cmd.get_name().to_string();
346 generate(shell, &mut cmd, name, &mut std::io::stdout());
347 return Ok(());
348 }
349
350 if cli.clear_cache {
351 let introspect = crate::load::introspect::clear_cache();
352 let records = crate::load::record_cache::clear();
353 #[cfg(feature = "_semantic")]
354 let vectors = crate::semantic::clear_cache();
355 #[cfg(not(feature = "_semantic"))]
356 let vectors = 0;
357 crate::status!("cleared {} cached file(s)", introspect + records + vectors);
358 return Ok(());
359 }
360
361 let output = if cli.json {
362 Output::Json
363 } else if cli.ndjson {
364 Output::Ndjson
365 } else {
366 Output::Text {
367 descriptions: !cli.no_description,
368 }
369 };
370
371 let kind: Option<Kind> = match &cli.kind {
372 Some(s) => Some(s.parse()?),
373 None => None,
374 };
375
376 let positional_is_source = cli.source.is_none()
383 && cli.returns.is_some()
384 && cli.query.as_deref().is_some_and(looks_like_source);
385
386 let source = if let Some(s) = cli.source.clone() {
387 s
388 } else if cli.warm || positional_is_source {
389 match cli.query.clone() {
390 Some(s) => s,
391 None => load::discover()?,
392 }
393 } else {
394 load::discover()?
395 };
396 let load_opts = load::LoadOptions {
397 headers: parse_headers(&cli.header)?,
398 refresh: cli.refresh,
399 };
400 let t_load = std::time::Instant::now();
401 let records = {
402 let mut span = crate::profile::span("load");
403 let records = load::load(&source, &load_opts)?;
404 span.note(|| format!("{} records", records.len()));
405 records
406 };
407 crate::detail!(
408 "loaded {} records in {:.1?}",
409 records.len(),
410 t_load.elapsed()
411 );
412
413 if cli.warm {
416 #[cfg(feature = "_semantic")]
417 {
418 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
419 crate::status!("cached vectors for {n} record(s)");
420 return Ok(());
421 }
422 #[cfg(not(feature = "_semantic"))]
423 {
424 let _ = (&cli.model, cli.refresh);
425 anyhow::bail!("--warm needs a semantic build");
426 }
427 }
428
429 let query = match cli.query.as_deref() {
431 Some(q) if !positional_is_source => q,
433 _ if cli.returns.is_some() => "*",
435 _ => anyhow::bail!("a QUERY is required (see --help)"),
436 };
437
438 let pattern = search::glob::is_pattern(query);
442 if pattern {
443 crate::detail!("wildcard query — enumerating matches for {query:?}");
444 }
445
446 let spaced = (!pattern)
451 .then(|| search::spaced_qualifier(query, &records))
452 .flatten();
453 let loose = spaced.is_some();
454 let query = spaced.as_deref().unwrap_or(query);
455 if loose {
456 crate::detail!("two-word query names a type — searching as {query:?}");
457 }
458
459 let parent = (!pattern)
464 .then(|| search::parent_filter(query, &records))
465 .flatten();
466 if let Some(p) = parent {
467 let (_, qualifier) = search::score::parse_qualified(query);
468 if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
469 crate::detail!("qualifier {p:?} names a type — restricting to its members");
470 } else {
471 crate::status!(
472 "no type named {:?} — using closest match {p:?}",
473 qualifier.unwrap_or_default()
474 );
475 }
476 }
477
478 let filters = search::Filters {
479 kind,
480 parent,
481 returns: cli.returns.as_deref(),
482 };
483
484 if cli.resolve {
485 return run_resolve(
486 query,
487 &source,
488 &records,
489 filters,
490 cli.code.as_deref(),
491 cli.limit,
492 output,
493 );
494 }
495
496 if cli.example {
497 return run_example(query, &records, filters, cli.depth, output);
498 }
499
500 let t_rank = std::time::Instant::now();
504 let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
505 let (mut fuzzy, _) = fuzzy_matches(query, &records, filters);
506 let total = fuzzy.len();
507 fuzzy.truncate(cli.limit);
508 (fuzzy, total)
509 } else if cli.semantic {
510 #[cfg(feature = "_semantic")]
511 {
512 if pattern {
513 crate::status!("--semantic ranks by meaning and ignores wildcards in {query:?}");
514 }
515 let matches = semantic_matches(query, &records, filters, &cli);
516 let total = matches.len();
517 (matches, total)
518 }
519 #[cfg(not(feature = "_semantic"))]
520 {
521 let _ = (&cli.model, cli.refresh);
522 anyhow::bail!(
523 "this build has no semantic search — install it with \
524 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
525 );
526 }
527 } else {
528 let (mut fuzzy, named) = fuzzy_matches(query, &records, filters);
535 let total = fuzzy.len();
536 fuzzy.truncate(cli.limit);
537 #[cfg(feature = "_semantic")]
538 {
539 let skip = if pattern {
543 Some("wildcard enumeration")
544 } else if named && !loose {
545 Some("strong name match")
546 } else {
547 None
548 };
549 if let Some(why) = skip {
550 crate::detail!("{why} — semantic ranking skipped (--semantic to force)");
551 (fuzzy, total)
552 } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
553 let semantic = semantic_matches(query, &records, filters, &cli);
554 (combine(fuzzy, semantic, cli.limit), total)
555 } else {
556 spawn_background_warm(&source, &cli.header);
557 crate::status!(
558 "building the semantic index in the background; next run ranks by \
559 meaning (--semantic to wait, --fuzzy to skip)"
560 );
561 (fuzzy, total)
562 }
563 }
564 #[cfg(not(feature = "_semantic"))]
565 {
566 let _ = (&cli.model, cli.refresh, named, loose);
567 (fuzzy, total)
568 }
569 };
570
571 crate::detail!("ranked in {:.1?}", t_rank.elapsed());
572 let out_span = crate::profile::span("output");
573
574 if matches.is_empty() {
575 crate::status!("no matches for {query:?}");
576 }
577 output.write_matches(&matches)?;
578 drop(out_span);
579 if crate::profile::enabled() {
580 match output {
584 Output::Json | Output::Ndjson => {
585 eprintln!("{}", crate::profile::json(started.elapsed()));
586 }
587 Output::Text { .. } => {
588 for line in crate::profile::report(started.elapsed()) {
589 eprintln!("{line}");
590 }
591 }
592 }
593 }
594 if total > matches.len() {
595 crate::detail!(
596 "{total} matches; showing top {} (-l to adjust)",
597 matches.len()
598 );
599 }
600 Ok(())
601}
602
603impl Output {
604 fn write_matches(self, matches: &[Match]) -> Result<()> {
605 #[derive(Serialize)]
606 struct Row<'a> {
607 #[serde(flatten)]
608 record: &'a SchemaRecord,
609 score: f64,
610 }
611 let rows = || {
612 matches.iter().map(|m| Row {
613 record: m.record,
614 score: m.score,
615 })
616 };
617 match self {
618 Output::Json => println!(
619 "{}",
620 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
621 ),
622 Output::Ndjson => {
623 for row in rows() {
624 println!("{}", serde_json::to_string(&row)?);
625 }
626 }
627 Output::Text { descriptions } => print_text(matches, descriptions),
628 }
629 Ok(())
630 }
631}
632
633const DESCRIPTION_WIDTH: usize = 72;
637
638fn print_text(matches: &[Match], descriptions: bool) {
639 let width = matches
640 .iter()
641 .map(|m| display_path(m.record).len())
642 .max()
643 .unwrap_or(0)
644 .min(48);
645
646 for m in matches {
647 let r = m.record;
648 let path = display_path(r);
649 let ret = r
650 .type_ref
651 .as_deref()
652 .map(|t| format!(" -> {t}"))
653 .unwrap_or_default();
654 let dep = if r.deprecated.is_some() {
655 " (deprecated)"
656 } else {
657 ""
658 };
659 let desc = if descriptions {
660 r.description
661 .as_deref()
662 .map(summarize)
663 .filter(|d| !d.is_empty())
664 .map(|d| format!(" — {d}"))
665 .unwrap_or_default()
666 } else {
667 String::new()
668 };
669 println!(
670 "{path:<width$}{ret} [{kind}]{dep}{desc}",
671 kind = r.kind.as_str()
672 );
673 }
674}
675
676fn summarize(description: &str) -> String {
679 let mut out = String::new();
680 for (i, word) in description.split_whitespace().enumerate() {
681 if i > 0 {
682 out.push(' ');
683 }
684 out.push_str(word);
685 if out.chars().count() > DESCRIPTION_WIDTH {
686 let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
687 return format!("{}…", kept.trim_end());
688 }
689 }
690 out
691}
692
693fn run_example(
695 query: &str,
696 records: &[SchemaRecord],
697 filters: search::Filters<'_>,
698 depth: usize,
699 output: Output,
700) -> Result<()> {
701 let Some(top) = search::search(query, records, filters).into_iter().next() else {
702 anyhow::bail!("no schema entity matches {query:?} to draft");
703 };
704 crate::status!("drafting an operation for {}", top.record.path);
705 let example = crate::example::build(top.record, records, depth)?;
706 if !example.deprecated.is_empty() {
707 crate::status!(
710 "deprecated: {} (flagged inline)",
711 example.deprecated.join(", ")
712 );
713 }
714 if let Some(via) = &example.via {
715 if example.alternatives.is_empty() {
716 crate::detail!("reached through {via}");
717 } else {
718 crate::status!(
721 "reached through {via}; also reachable via {}",
722 example.alternatives.join(", ")
723 );
724 }
725 }
726
727 let payload = serde_json::json!({
728 "path": top.record.path,
729 "operation": example.operation,
730 "variables": example.variables,
731 "optional_args": example.optional,
732 "input_types": example.input_types,
733 "deprecated": example.deprecated,
734 });
735 match output {
736 Output::Json => println!("{}", serde_json::to_string_pretty(&payload)?),
737 Output::Ndjson => println!("{}", serde_json::to_string(&payload)?),
738 Output::Text { .. } => {
739 print!("{}", example.operation);
740 if !example.optional.is_empty() {
741 println!("\n# optional arguments, omitted above:");
742 for arg in &example.optional {
743 println!("# {arg}");
744 }
745 }
746 if !example.input_types.is_empty() {
747 println!("\n# input types:");
748 for block in &example.input_types {
749 for line in block {
750 println!("# {line}");
751 }
752 }
753 }
754 println!("\n# variables");
755 println!("{}", serde_json::to_string_pretty(&example.variables)?);
756 }
757 }
758 Ok(())
759}
760
761fn run_resolve(
763 query: &str,
764 source: &str,
765 records: &[SchemaRecord],
766 filters: search::Filters<'_>,
767 code: Option<&str>,
768 limit: usize,
769 output: Output,
770) -> Result<()> {
771 if code.is_none() {
772 crate::status!("searching code in the current directory (--code to search elsewhere)");
773 }
774 let Some(top) = search::search(query, records, filters).into_iter().next() else {
775 anyhow::bail!("no schema entity matches {query:?} to resolve");
776 };
777 crate::status!("resolving {} …", top.record.path);
778 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
780 .then(|| std::path::Path::new(source))
781 .filter(|p| p.exists());
782 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
783
784 match output {
785 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
786 Output::Ndjson => {
787 for h in &hits {
788 println!("{}", serde_json::to_string(h)?);
789 }
790 }
791 Output::Text { .. } => {
792 if hits.is_empty() {
793 crate::status!(
794 "no code definition found for {} (-v shows what was tried)",
795 top.record.path
796 );
797 }
798 if hits.first().is_some_and(|h| h.loose) {
802 crate::status!(
803 "no graphql-ruby convention matched {} — these are name-similarity \
804 guesses, not resolver lookups",
805 top.record.path
806 );
807 }
808 for h in &hits {
809 let flag = if h.loose { " (guess)" } else { "" };
810 println!("{}:{} {} (via {}){flag}", h.file, h.line, h.name, h.via);
811 }
812 }
813 }
814 Ok(())
815}
816
817fn looks_like_source(arg: &str) -> bool {
822 arg.starts_with("http://")
823 || arg.starts_with("https://")
824 || [".graphql", ".graphqls", ".gql", ".json"]
825 .iter()
826 .any(|ext| arg.to_ascii_lowercase().ends_with(ext))
827}
828
829fn display_path(r: &SchemaRecord) -> String {
831 if r.args.is_empty() {
832 r.path.clone()
833 } else {
834 format!("{}({})", r.path, r.args.join(", "))
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::{looks_like_source, summarize, DESCRIPTION_WIDTH};
841
842 #[test]
843 fn recognizes_schema_sources_but_not_queries() {
844 assert!(looks_like_source("schema.graphql"));
845 assert!(looks_like_source("a/b/Schema.GraphQLS"));
846 assert!(looks_like_source("dump.json"));
847 assert!(looks_like_source("https://api.example.com/graphql"));
848 assert!(!looks_like_source("User.email"));
850 assert!(!looks_like_source("Company"));
851 assert!(!looks_like_source("User.*"));
852 }
853
854 #[test]
855 fn collapses_block_descriptions_to_one_line() {
856 assert_eq!(summarize(" Look up\n a user.\n"), "Look up a user.");
857 assert_eq!(summarize(" "), "");
858 }
859
860 #[test]
861 fn elides_past_the_width() {
862 let long = "word ".repeat(60);
863 let out = summarize(&long);
864 assert!(out.ends_with('…'), "{out}");
865 assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
866 }
867
868 #[test]
869 fn keeps_a_description_that_fits() {
870 let s = "An account.";
871 assert_eq!(summarize(s), s);
872 }
873}