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