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