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 repo schema.json search a local introspection dump
23 gqls repo https://api/graphql introspect a live endpoint
24 gqls 'cancel a subscription' rank by meaning (fuzzy + semantic, auto)
25 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
26 gqls user schema.graphql -j JSON output (-J for ndjson)
27";
28
29#[cfg(not(feature = "_semantic"))]
30const EXAMPLES: &str = "\
31EXAMPLES:
32 gqls user schema.graphql fuzzy search an SDL file
33 gqls createUser -k mutation restrict to a kind (schema auto-discovered)
34 gqls User.email qualified Type.field query
35 gqls repo schema.json search a local introspection dump
36 gqls repo https://api/graphql introspect a live endpoint
37 gqls Query.user -R --code ./app jump to the graphql-ruby resolver
38 gqls user schema.graphql -j JSON output (-J for ndjson)
39
40Semantic search (--semantic, rank by meaning) is not compiled into this build. Enable it:
41 cargo install gqls-cli --features semantic
42 brew install dpep/tools/gqls
43";
44
45#[derive(Parser)]
46#[command(
47 name = "gqls",
48 version,
49 about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
50 long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
51 terminal. The source is an SDL file, a local introspection JSON dump, or a live \
52 http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
53 Fuzzy and semantic results are ranked together by default (--semantic or \
54 --fuzzy forces one); --resolve jumps to the graphql-ruby resolver via rq. All modes \
55 support -j/--json and -J/--ndjson.",
56 after_help = EXAMPLES
57)]
58struct Cli {
59 #[arg(required_unless_present_any = ["clear_cache", "completions", "warm"])]
62 query: Option<String>,
63
64 source: Option<String>,
68
69 #[arg(short, long)]
71 kind: Option<String>,
72
73 #[arg(short, long, default_value_t = 20)]
75 limit: usize,
76
77 #[arg(short, long, conflicts_with = "ndjson")]
79 json: bool,
80
81 #[arg(short = 'J', long)]
83 ndjson: bool,
84
85 #[arg(long, hide = HIDE_SEMANTIC)]
88 semantic: bool,
89
90 #[arg(long, conflicts_with = "semantic")]
92 fuzzy: bool,
93
94 #[arg(long, hide = HIDE_SEMANTIC)]
97 model: Option<String>,
98
99 #[arg(long, hide = HIDE_SEMANTIC)]
103 refresh: bool,
104
105 #[arg(long, hide = HIDE_SEMANTIC)]
107 clear_cache: bool,
108
109 #[arg(long, hide = HIDE_SEMANTIC)]
111 warm: bool,
112
113 #[arg(long, value_name = "SHELL")]
115 completions: Option<Shell>,
116
117 #[arg(short = 'R', long)]
120 resolve: bool,
121
122 #[arg(long)]
124 code: Option<String>,
125
126 #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
129 header: Vec<String>,
130
131 #[arg(short, long, conflicts_with = "quiet")]
134 verbose: bool,
135
136 #[arg(short, long)]
138 quiet: bool,
139}
140
141#[derive(Clone, Copy)]
143enum Output {
144 Text,
145 Json,
146 Ndjson,
147}
148
149struct Match<'a> {
152 record: &'a SchemaRecord,
153 score: f64,
154}
155
156fn fuzzy_matches<'a>(
160 query: &str,
161 records: &'a [SchemaRecord],
162 kind: Option<Kind>,
163 parent: Option<&str>,
164) -> (Vec<Match<'a>>, bool) {
165 let hits = search::search(query, records, kind, parent);
166 let named = search::named_hit(query, &hits);
167 let matches = hits
168 .into_iter()
169 .map(|h| Match {
170 record: h.record,
171 score: h.score as f64,
172 })
173 .collect();
174 (matches, named)
175}
176
177#[cfg(feature = "_semantic")]
178fn semantic_matches<'a>(
179 query: &str,
180 records: &'a [SchemaRecord],
181 kind: Option<Kind>,
182 parent: Option<&str>,
183 cli: &Cli,
184) -> Vec<Match<'a>> {
185 crate::semantic::search(
186 query,
187 records,
188 kind,
189 parent,
190 cli.limit,
191 cli.model.as_deref(),
192 cli.refresh,
193 )
194 .into_iter()
195 .map(|(score, record)| Match { record, score })
196 .collect()
197}
198
199#[cfg(feature = "_semantic")]
204fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
205 use std::collections::HashMap;
206 const K: f64 = 60.0;
207 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
211 for (rank, m) in fuzzy.iter().enumerate() {
212 scored
213 .entry(m.record.path.as_str())
214 .or_insert((0.0, m.record))
215 .0 += 1.0 / (K + rank as f64 + 1.0);
216 }
217 for (rank, m) in semantic.iter().enumerate() {
218 scored
219 .entry(m.record.path.as_str())
220 .or_insert((0.0, m.record))
221 .0 += 0.7 / (K + rank as f64 + 1.0);
222 }
223 let mut merged: Vec<Match> = scored
224 .into_values()
225 .map(|(score, record)| Match { record, score })
226 .collect();
227 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
228 merged.truncate(limit);
229 merged
230}
231
232#[cfg(feature = "_semantic")]
236fn spawn_background_warm(source: &str, headers: &[String]) {
237 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
238 return;
239 }
240 if !claim_warm_lock(source) {
244 return;
245 }
246 if let Ok(exe) = std::env::current_exe() {
247 let mut cmd = std::process::Command::new(exe);
248 cmd.arg("--warm").arg(source);
249 for h in headers {
250 cmd.arg("--header").arg(h);
251 }
252 let _ = cmd
253 .stdin(std::process::Stdio::null())
254 .stdout(std::process::Stdio::null())
255 .stderr(std::process::Stdio::null())
256 .spawn();
257 }
258}
259
260#[cfg(feature = "_semantic")]
265fn claim_warm_lock(source: &str) -> bool {
266 use std::collections::hash_map::DefaultHasher;
267 use std::hash::{Hash, Hasher};
268 use std::time::Duration;
269
270 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
271 let dir = crate::paths::temp_dir();
273 let mut h = DefaultHasher::new();
274 source.hash(&mut h);
275 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
276 if let Ok(meta) = std::fs::metadata(&lock) {
277 if let Ok(modified) = meta.modified() {
278 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
279 return false; }
281 }
282 }
283 let _ = std::fs::create_dir_all(&dir);
284 std::fs::write(&lock, []).is_ok()
285}
286
287fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
289 raw.iter()
290 .map(|h| {
291 let (name, value) = h
292 .split_once(':')
293 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
294 Ok((name.trim().to_string(), value.trim().to_string()))
295 })
296 .collect()
297}
298
299pub fn run() -> Result<()> {
300 let cli = Cli::parse();
301 crate::logging::init(cli.verbose, cli.quiet);
302
303 if let Some(shell) = cli.completions {
304 let mut cmd = Cli::command();
305 let name = cmd.get_name().to_string();
306 generate(shell, &mut cmd, name, &mut std::io::stdout());
307 return Ok(());
308 }
309
310 if cli.clear_cache {
311 let introspect = crate::load::introspect::clear_cache();
312 let records = crate::load::record_cache::clear();
313 #[cfg(feature = "_semantic")]
314 let vectors = crate::semantic::clear_cache();
315 #[cfg(not(feature = "_semantic"))]
316 let vectors = 0;
317 crate::status!("cleared {} cached file(s)", introspect + records + vectors);
318 return Ok(());
319 }
320
321 let output = if cli.json {
322 Output::Json
323 } else if cli.ndjson {
324 Output::Ndjson
325 } else {
326 Output::Text
327 };
328
329 let kind: Option<Kind> = match &cli.kind {
330 Some(s) => Some(s.parse()?),
331 None => None,
332 };
333
334 let source = if let Some(s) = cli.source.clone() {
338 s
339 } else if cli.warm {
340 match cli.query.clone() {
341 Some(s) => s,
342 None => load::discover()?,
343 }
344 } else {
345 load::discover()?
346 };
347 let load_opts = load::LoadOptions {
348 headers: parse_headers(&cli.header)?,
349 refresh: cli.refresh,
350 };
351 let t_load = std::time::Instant::now();
352 let records = load::load(&source, &load_opts)?;
353 crate::detail!(
354 "loaded {} records in {:.1?}",
355 records.len(),
356 t_load.elapsed()
357 );
358
359 if cli.warm {
362 #[cfg(feature = "_semantic")]
363 {
364 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
365 crate::status!("cached vectors for {n} record(s)");
366 return Ok(());
367 }
368 #[cfg(not(feature = "_semantic"))]
369 {
370 let _ = (&cli.model, cli.refresh);
371 anyhow::bail!("--warm needs a semantic build");
372 }
373 }
374
375 let query = cli
377 .query
378 .as_deref()
379 .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
380
381 let spaced = search::spaced_qualifier(query, &records);
386 let loose = spaced.is_some();
387 let query = spaced.as_deref().unwrap_or(query);
388 if loose {
389 crate::detail!("two-word query names a type — searching as {query:?}");
390 }
391
392 let parent = search::parent_filter(query, &records);
397 if let Some(p) = parent {
398 let (_, qualifier) = search::score::parse_qualified(query);
399 if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
400 crate::detail!("qualifier {p:?} names a type — restricting to its members");
401 } else {
402 crate::status!(
403 "no type named {:?} — using closest match {p:?}",
404 qualifier.unwrap_or_default()
405 );
406 }
407 }
408
409 if cli.resolve {
410 return run_resolve(
411 query,
412 &source,
413 &records,
414 kind,
415 parent,
416 cli.code.as_deref(),
417 cli.limit,
418 output,
419 );
420 }
421
422 let t_rank = std::time::Instant::now();
426 let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
427 let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent);
428 let total = fuzzy.len();
429 fuzzy.truncate(cli.limit);
430 (fuzzy, total)
431 } else if cli.semantic {
432 #[cfg(feature = "_semantic")]
433 {
434 let matches = semantic_matches(query, &records, kind, parent, &cli);
435 let total = matches.len();
436 (matches, total)
437 }
438 #[cfg(not(feature = "_semantic"))]
439 {
440 let _ = (&cli.model, cli.refresh);
441 anyhow::bail!(
442 "this build has no semantic search — install it with \
443 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
444 );
445 }
446 } else {
447 let (mut fuzzy, named) = fuzzy_matches(query, &records, kind, parent);
454 let total = fuzzy.len();
455 fuzzy.truncate(cli.limit);
456 #[cfg(feature = "_semantic")]
457 {
458 if named && !loose {
459 crate::detail!(
460 "strong name match — semantic ranking skipped (--semantic to force)"
461 );
462 (fuzzy, total)
463 } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
464 let semantic = semantic_matches(query, &records, kind, parent, &cli);
465 (combine(fuzzy, semantic, cli.limit), total)
466 } else {
467 spawn_background_warm(&source, &cli.header);
468 crate::status!(
469 "building the semantic index in the background; next run ranks by \
470 meaning (--semantic to wait, --fuzzy to skip)"
471 );
472 (fuzzy, total)
473 }
474 }
475 #[cfg(not(feature = "_semantic"))]
476 {
477 let _ = (&cli.model, cli.refresh, named, loose);
478 (fuzzy, total)
479 }
480 };
481
482 crate::detail!("ranked in {:.1?}", t_rank.elapsed());
483
484 if matches.is_empty() {
485 crate::status!("no matches for {query:?}");
486 }
487 output.write_matches(&matches)?;
488 if total > matches.len() {
489 crate::detail!(
490 "{total} matches; showing top {} (-l to adjust)",
491 matches.len()
492 );
493 }
494 Ok(())
495}
496
497impl Output {
498 fn write_matches(self, matches: &[Match]) -> Result<()> {
499 #[derive(Serialize)]
500 struct Row<'a> {
501 #[serde(flatten)]
502 record: &'a SchemaRecord,
503 score: f64,
504 }
505 let rows = || {
506 matches.iter().map(|m| Row {
507 record: m.record,
508 score: m.score,
509 })
510 };
511 match self {
512 Output::Json => println!(
513 "{}",
514 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
515 ),
516 Output::Ndjson => {
517 for row in rows() {
518 println!("{}", serde_json::to_string(&row)?);
519 }
520 }
521 Output::Text => print_text(matches),
522 }
523 Ok(())
524 }
525}
526
527fn print_text(matches: &[Match]) {
528 let width = matches
529 .iter()
530 .map(|m| display_path(m.record).len())
531 .max()
532 .unwrap_or(0)
533 .min(48);
534
535 for m in matches {
536 let r = m.record;
537 let path = display_path(r);
538 let ret = r
539 .type_ref
540 .as_deref()
541 .map(|t| format!(" -> {t}"))
542 .unwrap_or_default();
543 let dep = if r.deprecated.is_some() {
544 " (deprecated)"
545 } else {
546 ""
547 };
548 println!("{path:<width$}{ret} [{kind}]{dep}", kind = r.kind.as_str());
549 }
550}
551
552#[allow(clippy::too_many_arguments)]
554fn run_resolve(
555 query: &str,
556 source: &str,
557 records: &[SchemaRecord],
558 kind: Option<Kind>,
559 parent: Option<&str>,
560 code: Option<&str>,
561 limit: usize,
562 output: Output,
563) -> Result<()> {
564 if code.is_none() {
565 crate::status!("searching code in the current directory (--code to search elsewhere)");
566 }
567 let Some(top) = search::search(query, records, kind, parent)
568 .into_iter()
569 .next()
570 else {
571 anyhow::bail!("no schema entity matches {query:?} to resolve");
572 };
573 crate::status!("resolving {} …", top.record.path);
574 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
576 .then(|| std::path::Path::new(source))
577 .filter(|p| p.exists());
578 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
579
580 match output {
581 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
582 Output::Ndjson => {
583 for h in &hits {
584 println!("{}", serde_json::to_string(h)?);
585 }
586 }
587 Output::Text => {
588 if hits.is_empty() {
589 crate::status!(
590 "no code definition found for {} (-v shows what was tried)",
591 top.record.path
592 );
593 }
594 for h in &hits {
595 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
596 }
597 }
598 }
599 Ok(())
600}
601
602fn display_path(r: &SchemaRecord) -> String {
604 if r.args.is_empty() {
605 r.path.clone()
606 } else {
607 format!("{}({})", r.path, r.args.join(", "))
608 }
609}