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(short, long, conflicts_with = "quiet")]
166 verbose: bool,
167
168 #[arg(short, long)]
170 quiet: bool,
171}
172
173#[derive(Clone, Copy)]
176enum Output {
177 Text { descriptions: bool },
178 Json,
179 Ndjson,
180}
181
182struct Match<'a> {
185 record: &'a SchemaRecord,
186 score: f64,
187}
188
189fn fuzzy_matches<'a>(
193 query: &str,
194 records: &'a [SchemaRecord],
195 filters: search::Filters<'_>,
196) -> (Vec<Match<'a>>, bool) {
197 let hits = search::search(query, records, filters);
198 let named = search::named_hit(query, &hits);
199 let matches = hits
200 .into_iter()
201 .map(|h| Match {
202 record: h.record,
203 score: h.score as f64,
204 })
205 .collect();
206 (matches, named)
207}
208
209#[cfg(feature = "_semantic")]
210fn semantic_matches<'a>(
211 query: &str,
212 records: &'a [SchemaRecord],
213 filters: search::Filters<'_>,
214 cli: &Cli,
215) -> Vec<Match<'a>> {
216 crate::semantic::search(
217 query,
218 records,
219 filters,
220 cli.limit,
221 cli.model.as_deref(),
222 cli.refresh,
223 )
224 .into_iter()
225 .map(|(score, record)| Match { record, score })
226 .collect()
227}
228
229#[cfg(feature = "_semantic")]
234fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
235 use std::collections::HashMap;
236 const K: f64 = 60.0;
237 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
241 for (rank, m) in fuzzy.iter().enumerate() {
242 scored
243 .entry(m.record.path.as_str())
244 .or_insert((0.0, m.record))
245 .0 += 1.0 / (K + rank as f64 + 1.0);
246 }
247 for (rank, m) in semantic.iter().enumerate() {
248 scored
249 .entry(m.record.path.as_str())
250 .or_insert((0.0, m.record))
251 .0 += 0.7 / (K + rank as f64 + 1.0);
252 }
253 let mut merged: Vec<Match> = scored
254 .into_values()
255 .map(|(score, record)| Match { record, score })
256 .collect();
257 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
258 merged.truncate(limit);
259 merged
260}
261
262#[cfg(feature = "_semantic")]
266fn spawn_background_warm(source: &str, headers: &[String]) {
267 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
268 return;
269 }
270 if !claim_warm_lock(source) {
274 return;
275 }
276 if let Ok(exe) = std::env::current_exe() {
277 let mut cmd = std::process::Command::new(exe);
278 cmd.arg("--warm").arg(source);
279 for h in headers {
280 cmd.arg("--header").arg(h);
281 }
282 let _ = cmd
283 .stdin(std::process::Stdio::null())
284 .stdout(std::process::Stdio::null())
285 .stderr(std::process::Stdio::null())
286 .spawn();
287 }
288}
289
290#[cfg(feature = "_semantic")]
295fn claim_warm_lock(source: &str) -> bool {
296 use std::collections::hash_map::DefaultHasher;
297 use std::hash::{Hash, Hasher};
298 use std::time::Duration;
299
300 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
301 let dir = crate::paths::temp_dir();
303 let mut h = DefaultHasher::new();
304 source.hash(&mut h);
305 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
306 if let Ok(meta) = std::fs::metadata(&lock) {
307 if let Ok(modified) = meta.modified() {
308 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
309 return false; }
311 }
312 }
313 let _ = std::fs::create_dir_all(&dir);
314 std::fs::write(&lock, []).is_ok()
315}
316
317fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
319 raw.iter()
320 .map(|h| {
321 let (name, value) = h
322 .split_once(':')
323 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
324 Ok((name.trim().to_string(), value.trim().to_string()))
325 })
326 .collect()
327}
328
329pub fn run() -> Result<()> {
330 let cli = Cli::parse();
331 crate::logging::init(cli.verbose, cli.quiet);
332
333 if let Some(shell) = cli.completions {
334 let mut cmd = Cli::command();
335 let name = cmd.get_name().to_string();
336 generate(shell, &mut cmd, name, &mut std::io::stdout());
337 return Ok(());
338 }
339
340 if cli.clear_cache {
341 let introspect = crate::load::introspect::clear_cache();
342 let records = crate::load::record_cache::clear();
343 #[cfg(feature = "_semantic")]
344 let vectors = crate::semantic::clear_cache();
345 #[cfg(not(feature = "_semantic"))]
346 let vectors = 0;
347 crate::status!("cleared {} cached file(s)", introspect + records + vectors);
348 return Ok(());
349 }
350
351 let output = if cli.json {
352 Output::Json
353 } else if cli.ndjson {
354 Output::Ndjson
355 } else {
356 Output::Text {
357 descriptions: !cli.no_description,
358 }
359 };
360
361 let kind: Option<Kind> = match &cli.kind {
362 Some(s) => Some(s.parse()?),
363 None => None,
364 };
365
366 let positional_is_source = cli.source.is_none()
373 && cli.returns.is_some()
374 && cli.query.as_deref().is_some_and(looks_like_source);
375
376 let source = if let Some(s) = cli.source.clone() {
377 s
378 } else if cli.warm || positional_is_source {
379 match cli.query.clone() {
380 Some(s) => s,
381 None => load::discover()?,
382 }
383 } else {
384 load::discover()?
385 };
386 let load_opts = load::LoadOptions {
387 headers: parse_headers(&cli.header)?,
388 refresh: cli.refresh,
389 };
390 let t_load = std::time::Instant::now();
391 let records = load::load(&source, &load_opts)?;
392 crate::detail!(
393 "loaded {} records in {:.1?}",
394 records.len(),
395 t_load.elapsed()
396 );
397
398 if cli.warm {
401 #[cfg(feature = "_semantic")]
402 {
403 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
404 crate::status!("cached vectors for {n} record(s)");
405 return Ok(());
406 }
407 #[cfg(not(feature = "_semantic"))]
408 {
409 let _ = (&cli.model, cli.refresh);
410 anyhow::bail!("--warm needs a semantic build");
411 }
412 }
413
414 let query = match cli.query.as_deref() {
416 Some(q) if !positional_is_source => q,
418 _ if cli.returns.is_some() => "*",
420 _ => anyhow::bail!("a QUERY is required (see --help)"),
421 };
422
423 let pattern = search::glob::is_pattern(query);
427 if pattern {
428 crate::detail!("wildcard query — enumerating matches for {query:?}");
429 }
430
431 let spaced = (!pattern)
436 .then(|| search::spaced_qualifier(query, &records))
437 .flatten();
438 let loose = spaced.is_some();
439 let query = spaced.as_deref().unwrap_or(query);
440 if loose {
441 crate::detail!("two-word query names a type — searching as {query:?}");
442 }
443
444 let parent = (!pattern)
449 .then(|| search::parent_filter(query, &records))
450 .flatten();
451 if let Some(p) = parent {
452 let (_, qualifier) = search::score::parse_qualified(query);
453 if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
454 crate::detail!("qualifier {p:?} names a type — restricting to its members");
455 } else {
456 crate::status!(
457 "no type named {:?} — using closest match {p:?}",
458 qualifier.unwrap_or_default()
459 );
460 }
461 }
462
463 let filters = search::Filters {
464 kind,
465 parent,
466 returns: cli.returns.as_deref(),
467 };
468
469 if cli.resolve {
470 return run_resolve(
471 query,
472 &source,
473 &records,
474 filters,
475 cli.code.as_deref(),
476 cli.limit,
477 output,
478 );
479 }
480
481 if cli.example {
482 return run_example(query, &records, filters, cli.depth, output);
483 }
484
485 let t_rank = std::time::Instant::now();
489 let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
490 let (mut fuzzy, _) = fuzzy_matches(query, &records, filters);
491 let total = fuzzy.len();
492 fuzzy.truncate(cli.limit);
493 (fuzzy, total)
494 } else if cli.semantic {
495 #[cfg(feature = "_semantic")]
496 {
497 if pattern {
498 crate::status!("--semantic ranks by meaning and ignores wildcards in {query:?}");
499 }
500 let matches = semantic_matches(query, &records, filters, &cli);
501 let total = matches.len();
502 (matches, total)
503 }
504 #[cfg(not(feature = "_semantic"))]
505 {
506 let _ = (&cli.model, cli.refresh);
507 anyhow::bail!(
508 "this build has no semantic search — install it with \
509 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
510 );
511 }
512 } else {
513 let (mut fuzzy, named) = fuzzy_matches(query, &records, filters);
520 let total = fuzzy.len();
521 fuzzy.truncate(cli.limit);
522 #[cfg(feature = "_semantic")]
523 {
524 let skip = if pattern {
528 Some("wildcard enumeration")
529 } else if named && !loose {
530 Some("strong name match")
531 } else {
532 None
533 };
534 if let Some(why) = skip {
535 crate::detail!("{why} — semantic ranking skipped (--semantic to force)");
536 (fuzzy, total)
537 } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
538 let semantic = semantic_matches(query, &records, filters, &cli);
539 (combine(fuzzy, semantic, cli.limit), total)
540 } else {
541 spawn_background_warm(&source, &cli.header);
542 crate::status!(
543 "building the semantic index in the background; next run ranks by \
544 meaning (--semantic to wait, --fuzzy to skip)"
545 );
546 (fuzzy, total)
547 }
548 }
549 #[cfg(not(feature = "_semantic"))]
550 {
551 let _ = (&cli.model, cli.refresh, named, loose);
552 (fuzzy, total)
553 }
554 };
555
556 crate::detail!("ranked in {:.1?}", t_rank.elapsed());
557
558 if matches.is_empty() {
559 crate::status!("no matches for {query:?}");
560 }
561 output.write_matches(&matches)?;
562 if total > matches.len() {
563 crate::detail!(
564 "{total} matches; showing top {} (-l to adjust)",
565 matches.len()
566 );
567 }
568 Ok(())
569}
570
571impl Output {
572 fn write_matches(self, matches: &[Match]) -> Result<()> {
573 #[derive(Serialize)]
574 struct Row<'a> {
575 #[serde(flatten)]
576 record: &'a SchemaRecord,
577 score: f64,
578 }
579 let rows = || {
580 matches.iter().map(|m| Row {
581 record: m.record,
582 score: m.score,
583 })
584 };
585 match self {
586 Output::Json => println!(
587 "{}",
588 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
589 ),
590 Output::Ndjson => {
591 for row in rows() {
592 println!("{}", serde_json::to_string(&row)?);
593 }
594 }
595 Output::Text { descriptions } => print_text(matches, descriptions),
596 }
597 Ok(())
598 }
599}
600
601const DESCRIPTION_WIDTH: usize = 72;
605
606fn print_text(matches: &[Match], descriptions: bool) {
607 let width = matches
608 .iter()
609 .map(|m| display_path(m.record).len())
610 .max()
611 .unwrap_or(0)
612 .min(48);
613
614 for m in matches {
615 let r = m.record;
616 let path = display_path(r);
617 let ret = r
618 .type_ref
619 .as_deref()
620 .map(|t| format!(" -> {t}"))
621 .unwrap_or_default();
622 let dep = if r.deprecated.is_some() {
623 " (deprecated)"
624 } else {
625 ""
626 };
627 let desc = if descriptions {
628 r.description
629 .as_deref()
630 .map(summarize)
631 .filter(|d| !d.is_empty())
632 .map(|d| format!(" — {d}"))
633 .unwrap_or_default()
634 } else {
635 String::new()
636 };
637 println!(
638 "{path:<width$}{ret} [{kind}]{dep}{desc}",
639 kind = r.kind.as_str()
640 );
641 }
642}
643
644fn summarize(description: &str) -> String {
647 let mut out = String::new();
648 for (i, word) in description.split_whitespace().enumerate() {
649 if i > 0 {
650 out.push(' ');
651 }
652 out.push_str(word);
653 if out.chars().count() > DESCRIPTION_WIDTH {
654 let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
655 return format!("{}…", kept.trim_end());
656 }
657 }
658 out
659}
660
661fn run_example(
663 query: &str,
664 records: &[SchemaRecord],
665 filters: search::Filters<'_>,
666 depth: usize,
667 output: Output,
668) -> Result<()> {
669 let Some(top) = search::search(query, records, filters).into_iter().next() else {
670 anyhow::bail!("no schema entity matches {query:?} to draft");
671 };
672 crate::status!("drafting an operation for {}", top.record.path);
673 let example = crate::example::build(top.record, records, depth)?;
674 if !example.deprecated.is_empty() {
675 crate::status!(
678 "deprecated: {} (flagged inline)",
679 example.deprecated.join(", ")
680 );
681 }
682 if let Some(via) = &example.via {
683 if example.alternatives.is_empty() {
684 crate::detail!("reached through {via}");
685 } else {
686 crate::status!(
689 "reached through {via}; also reachable via {}",
690 example.alternatives.join(", ")
691 );
692 }
693 }
694
695 let payload = serde_json::json!({
696 "path": top.record.path,
697 "operation": example.operation,
698 "variables": example.variables,
699 "optional_args": example.optional,
700 "input_types": example.input_types,
701 "deprecated": example.deprecated,
702 });
703 match output {
704 Output::Json => println!("{}", serde_json::to_string_pretty(&payload)?),
705 Output::Ndjson => println!("{}", serde_json::to_string(&payload)?),
706 Output::Text { .. } => {
707 print!("{}", example.operation);
708 if !example.optional.is_empty() {
709 println!("\n# optional arguments, omitted above:");
710 for arg in &example.optional {
711 println!("# {arg}");
712 }
713 }
714 if !example.input_types.is_empty() {
715 println!("\n# input types:");
716 for block in &example.input_types {
717 for line in block {
718 println!("# {line}");
719 }
720 }
721 }
722 println!("\n# variables");
723 println!("{}", serde_json::to_string_pretty(&example.variables)?);
724 }
725 }
726 Ok(())
727}
728
729fn run_resolve(
731 query: &str,
732 source: &str,
733 records: &[SchemaRecord],
734 filters: search::Filters<'_>,
735 code: Option<&str>,
736 limit: usize,
737 output: Output,
738) -> Result<()> {
739 if code.is_none() {
740 crate::status!("searching code in the current directory (--code to search elsewhere)");
741 }
742 let Some(top) = search::search(query, records, filters).into_iter().next() else {
743 anyhow::bail!("no schema entity matches {query:?} to resolve");
744 };
745 crate::status!("resolving {} …", top.record.path);
746 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
748 .then(|| std::path::Path::new(source))
749 .filter(|p| p.exists());
750 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
751
752 match output {
753 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
754 Output::Ndjson => {
755 for h in &hits {
756 println!("{}", serde_json::to_string(h)?);
757 }
758 }
759 Output::Text { .. } => {
760 if hits.is_empty() {
761 crate::status!(
762 "no code definition found for {} (-v shows what was tried)",
763 top.record.path
764 );
765 }
766 if hits.first().is_some_and(|h| h.loose) {
770 crate::status!(
771 "no graphql-ruby convention matched {} — these are name-similarity \
772 guesses, not resolver lookups",
773 top.record.path
774 );
775 }
776 for h in &hits {
777 let flag = if h.loose { " (guess)" } else { "" };
778 println!("{}:{} {} (via {}){flag}", h.file, h.line, h.name, h.via);
779 }
780 }
781 }
782 Ok(())
783}
784
785fn looks_like_source(arg: &str) -> bool {
790 arg.starts_with("http://")
791 || arg.starts_with("https://")
792 || [".graphql", ".graphqls", ".gql", ".json"]
793 .iter()
794 .any(|ext| arg.to_ascii_lowercase().ends_with(ext))
795}
796
797fn display_path(r: &SchemaRecord) -> String {
799 if r.args.is_empty() {
800 r.path.clone()
801 } else {
802 format!("{}({})", r.path, r.args.join(", "))
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::{looks_like_source, summarize, DESCRIPTION_WIDTH};
809
810 #[test]
811 fn recognizes_schema_sources_but_not_queries() {
812 assert!(looks_like_source("schema.graphql"));
813 assert!(looks_like_source("a/b/Schema.GraphQLS"));
814 assert!(looks_like_source("dump.json"));
815 assert!(looks_like_source("https://api.example.com/graphql"));
816 assert!(!looks_like_source("User.email"));
818 assert!(!looks_like_source("Company"));
819 assert!(!looks_like_source("User.*"));
820 }
821
822 #[test]
823 fn collapses_block_descriptions_to_one_line() {
824 assert_eq!(summarize(" Look up\n a user.\n"), "Look up a user.");
825 assert_eq!(summarize(" "), "");
826 }
827
828 #[test]
829 fn elides_past_the_width() {
830 let long = "word ".repeat(60);
831 let out = summarize(&long);
832 assert!(out.ends_with('…'), "{out}");
833 assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
834 }
835
836 #[test]
837 fn keeps_a_description_that_fits() {
838 let s = "An account.";
839 assert_eq!(summarize(s), s);
840 }
841}