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