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>(
161 query: &str,
162 records: &'a [SchemaRecord],
163 kind: Option<Kind>,
164 parent: Option<&str>,
165) -> (Vec<Match<'a>>, bool) {
166 let hits = search::search(query, records, kind, parent);
167 let exact = search::has_exact(query, &hits);
168 let matches = hits
169 .into_iter()
170 .map(|h| Match {
171 record: h.record,
172 score: h.score as f64,
173 })
174 .collect();
175 (matches, exact)
176}
177
178#[cfg(feature = "_semantic")]
179fn semantic_matches<'a>(
180 query: &str,
181 records: &'a [SchemaRecord],
182 kind: Option<Kind>,
183 parent: Option<&str>,
184 cli: &Cli,
185) -> Vec<Match<'a>> {
186 crate::semantic::search(
187 query,
188 records,
189 kind,
190 parent,
191 cli.limit,
192 cli.model.as_deref(),
193 cli.refresh,
194 )
195 .into_iter()
196 .map(|(score, record)| Match { record, score })
197 .collect()
198}
199
200#[cfg(feature = "_semantic")]
205fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
206 use std::collections::HashMap;
207 const K: f64 = 60.0;
208 let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
212 for (rank, m) in fuzzy.iter().enumerate() {
213 scored
214 .entry(m.record.path.as_str())
215 .or_insert((0.0, m.record))
216 .0 += 1.0 / (K + rank as f64 + 1.0);
217 }
218 for (rank, m) in semantic.iter().enumerate() {
219 scored
220 .entry(m.record.path.as_str())
221 .or_insert((0.0, m.record))
222 .0 += 0.7 / (K + rank as f64 + 1.0);
223 }
224 let mut merged: Vec<Match> = scored
225 .into_values()
226 .map(|(score, record)| Match { record, score })
227 .collect();
228 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
229 merged.truncate(limit);
230 merged
231}
232
233#[cfg(feature = "_semantic")]
237fn spawn_background_warm(source: &str, headers: &[String]) {
238 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
239 return;
240 }
241 if !claim_warm_lock(source) {
245 return;
246 }
247 if let Ok(exe) = std::env::current_exe() {
248 let mut cmd = std::process::Command::new(exe);
249 cmd.arg("--warm").arg(source);
250 for h in headers {
251 cmd.arg("--header").arg(h);
252 }
253 let _ = cmd
254 .stdin(std::process::Stdio::null())
255 .stdout(std::process::Stdio::null())
256 .stderr(std::process::Stdio::null())
257 .spawn();
258 }
259}
260
261#[cfg(feature = "_semantic")]
266fn claim_warm_lock(source: &str) -> bool {
267 use std::collections::hash_map::DefaultHasher;
268 use std::hash::{Hash, Hasher};
269 use std::time::Duration;
270
271 const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
272 let dir = crate::paths::temp_dir();
274 let mut h = DefaultHasher::new();
275 source.hash(&mut h);
276 let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
277 if let Ok(meta) = std::fs::metadata(&lock) {
278 if let Ok(modified) = meta.modified() {
279 if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
280 return false; }
282 }
283 }
284 let _ = std::fs::create_dir_all(&dir);
285 std::fs::write(&lock, []).is_ok()
286}
287
288fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
290 raw.iter()
291 .map(|h| {
292 let (name, value) = h
293 .split_once(':')
294 .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
295 Ok((name.trim().to_string(), value.trim().to_string()))
296 })
297 .collect()
298}
299
300pub fn run() -> Result<()> {
301 let cli = Cli::parse();
302 crate::logging::init(cli.verbose, cli.quiet);
303
304 if let Some(shell) = cli.completions {
305 let mut cmd = Cli::command();
306 let name = cmd.get_name().to_string();
307 generate(shell, &mut cmd, name, &mut std::io::stdout());
308 return Ok(());
309 }
310
311 if cli.clear_cache {
312 let introspect = crate::load::introspect::clear_cache();
313 let records = crate::load::record_cache::clear();
314 #[cfg(feature = "_semantic")]
315 let vectors = crate::semantic::clear_cache();
316 #[cfg(not(feature = "_semantic"))]
317 let vectors = 0;
318 crate::status!("cleared {} cached file(s)", introspect + records + vectors);
319 return Ok(());
320 }
321
322 let output = if cli.json {
323 Output::Json
324 } else if cli.ndjson {
325 Output::Ndjson
326 } else {
327 Output::Text
328 };
329
330 let kind: Option<Kind> = match &cli.kind {
331 Some(s) => Some(s.parse()?),
332 None => None,
333 };
334
335 let source = if let Some(s) = cli.source.clone() {
339 s
340 } else if cli.warm {
341 match cli.query.clone() {
342 Some(s) => s,
343 None => load::discover()?,
344 }
345 } else {
346 load::discover()?
347 };
348 let load_opts = load::LoadOptions {
349 headers: parse_headers(&cli.header)?,
350 refresh: cli.refresh,
351 };
352 let t_load = std::time::Instant::now();
353 let records = load::load(&source, &load_opts)?;
354 crate::detail!(
355 "loaded {} records in {:.1?}",
356 records.len(),
357 t_load.elapsed()
358 );
359
360 if cli.warm {
363 #[cfg(feature = "_semantic")]
364 {
365 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
366 crate::status!("warmed {n} record vector(s) into the cache");
367 return Ok(());
368 }
369 #[cfg(not(feature = "_semantic"))]
370 {
371 let _ = (&cli.model, cli.refresh);
372 anyhow::bail!("--warm needs a semantic build");
373 }
374 }
375
376 let query = cli
378 .query
379 .as_deref()
380 .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
381
382 let parent = search::parent_filter(query, &records);
387 if let Some(p) = parent {
388 let (_, qualifier) = search::score::parse_qualified(query);
389 if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
390 crate::detail!("qualifier {p:?} names a type — restricting to its members");
391 } else {
392 crate::status!(
393 "no type named {:?} — filtering to the closest match, {p:?}",
394 qualifier.unwrap_or_default()
395 );
396 }
397 }
398
399 if cli.resolve {
400 return run_resolve(
401 query,
402 &source,
403 &records,
404 kind,
405 parent,
406 cli.code.as_deref(),
407 cli.limit,
408 output,
409 );
410 }
411
412 let t_rank = std::time::Instant::now();
416 let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
417 let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent);
418 let total = fuzzy.len();
419 fuzzy.truncate(cli.limit);
420 (fuzzy, total)
421 } else if cli.semantic {
422 #[cfg(feature = "_semantic")]
423 {
424 let matches = semantic_matches(query, &records, kind, parent, &cli);
425 let total = matches.len();
426 (matches, total)
427 }
428 #[cfg(not(feature = "_semantic"))]
429 {
430 let _ = (&cli.model, cli.refresh);
431 anyhow::bail!(
432 "this build has no semantic search — install it with \
433 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
434 );
435 }
436 } else {
437 let (mut fuzzy, exact) = fuzzy_matches(query, &records, kind, parent);
443 let total = fuzzy.len();
444 fuzzy.truncate(cli.limit);
445 #[cfg(feature = "_semantic")]
446 {
447 if exact {
448 crate::detail!("exact name match — semantic ranking skipped (--semantic to force)");
449 (fuzzy, total)
450 } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
451 let semantic = semantic_matches(query, &records, kind, parent, &cli);
452 (combine(fuzzy, semantic, cli.limit), total)
453 } else {
454 spawn_background_warm(&source, &cli.header);
455 crate::status!(
456 "warming the semantic index in the background — the next run also \
457 ranks by meaning (--semantic to embed now, --fuzzy to skip)"
458 );
459 (fuzzy, total)
460 }
461 }
462 #[cfg(not(feature = "_semantic"))]
463 {
464 let _ = (&cli.model, cli.refresh, exact);
465 (fuzzy, total)
466 }
467 };
468
469 crate::detail!("ranked in {:.1?}", t_rank.elapsed());
470
471 if matches.is_empty() {
472 crate::status!("no matches for {query:?}");
473 }
474 output.write_matches(&matches)?;
475 if total > matches.len() {
476 crate::detail!(
477 "{total} matches; showing top {} (-l to adjust)",
478 matches.len()
479 );
480 }
481 Ok(())
482}
483
484impl Output {
485 fn write_matches(self, matches: &[Match]) -> Result<()> {
486 #[derive(Serialize)]
487 struct Row<'a> {
488 #[serde(flatten)]
489 record: &'a SchemaRecord,
490 score: f64,
491 }
492 let rows = || {
493 matches.iter().map(|m| Row {
494 record: m.record,
495 score: m.score,
496 })
497 };
498 match self {
499 Output::Json => println!(
500 "{}",
501 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
502 ),
503 Output::Ndjson => {
504 for row in rows() {
505 println!("{}", serde_json::to_string(&row)?);
506 }
507 }
508 Output::Text => print_text(matches),
509 }
510 Ok(())
511 }
512}
513
514fn print_text(matches: &[Match]) {
515 let width = matches
516 .iter()
517 .map(|m| display_path(m.record).len())
518 .max()
519 .unwrap_or(0)
520 .min(48);
521
522 for m in matches {
523 let r = m.record;
524 let path = display_path(r);
525 let ret = r
526 .type_ref
527 .as_deref()
528 .map(|t| format!(" -> {t}"))
529 .unwrap_or_default();
530 let dep = if r.deprecated.is_some() {
531 " (deprecated)"
532 } else {
533 ""
534 };
535 println!("{path:<width$}{ret} [{kind}]{dep}", kind = r.kind.as_str());
536 }
537}
538
539#[allow(clippy::too_many_arguments)]
541fn run_resolve(
542 query: &str,
543 source: &str,
544 records: &[SchemaRecord],
545 kind: Option<Kind>,
546 parent: Option<&str>,
547 code: Option<&str>,
548 limit: usize,
549 output: Output,
550) -> Result<()> {
551 if code.is_none() {
552 crate::status!("no --code given; resolving against rq's index for the current directory");
553 }
554 let Some(top) = search::search(query, records, kind, parent)
555 .into_iter()
556 .next()
557 else {
558 anyhow::bail!("no schema entity matches {query:?} to resolve");
559 };
560 crate::status!("resolving {} …", top.record.path);
561 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
563 .then(|| std::path::Path::new(source))
564 .filter(|p| p.exists());
565 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
566
567 match output {
568 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
569 Output::Ndjson => {
570 for h in &hits {
571 println!("{}", serde_json::to_string(h)?);
572 }
573 }
574 Output::Text => {
575 if hits.is_empty() {
576 crate::status!(
577 "no code definition found for {} (tried graphql-ruby rq candidates)",
578 top.record.path
579 );
580 }
581 for h in &hits {
582 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
583 }
584 }
585 }
586 Ok(())
587}
588
589fn display_path(r: &SchemaRecord) -> String {
591 if r.args.is_empty() {
592 r.path.clone()
593 } else {
594 format!("{}({})", r.path, r.args.join(", "))
595 }
596}