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