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, long, conflicts_with = "quiet")]
129 verbose: bool,
130
131 #[arg(short, long)]
133 quiet: bool,
134}
135
136#[derive(Clone, Copy)]
138enum Output {
139 Text,
140 Json,
141 Ndjson,
142}
143
144struct Match<'a> {
147 record: &'a SchemaRecord,
148 score: f64,
149}
150
151fn fuzzy_matches<'a>(
152 query: &str,
153 records: &'a [SchemaRecord],
154 kind: Option<Kind>,
155 limit: usize,
156) -> Vec<Match<'a>> {
157 search::search(query, records, kind, limit)
158 .into_iter()
159 .map(|h| Match {
160 record: h.record,
161 score: h.score as f64,
162 })
163 .collect()
164}
165
166#[cfg(feature = "_semantic")]
167fn semantic_matches<'a>(
168 query: &str,
169 records: &'a [SchemaRecord],
170 kind: Option<Kind>,
171 cli: &Cli,
172) -> Vec<Match<'a>> {
173 crate::semantic::search(
174 query,
175 records,
176 kind,
177 cli.limit,
178 cli.model.as_deref(),
179 cli.refresh,
180 )
181 .into_iter()
182 .map(|(score, record)| Match { record, score })
183 .collect()
184}
185
186#[cfg(feature = "_semantic")]
191fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
192 use std::collections::HashMap;
193 const K: f64 = 60.0;
194 let mut scored: HashMap<*const SchemaRecord, (f64, &SchemaRecord)> = HashMap::new();
195 for (rank, m) in fuzzy.iter().enumerate() {
196 scored
197 .entry(m.record as *const _)
198 .or_insert((0.0, m.record))
199 .0 += 1.0 / (K + rank as f64 + 1.0);
200 }
201 for (rank, m) in semantic.iter().enumerate() {
202 scored
203 .entry(m.record as *const _)
204 .or_insert((0.0, m.record))
205 .0 += 0.7 / (K + rank as f64 + 1.0);
206 }
207 let mut merged: Vec<Match> = scored
208 .into_values()
209 .map(|(score, record)| Match { record, score })
210 .collect();
211 merged.sort_by(|a, b| b.score.total_cmp(&a.score));
212 merged.truncate(limit);
213 merged
214}
215
216#[cfg(feature = "_semantic")]
220fn spawn_background_warm(source: &str) {
221 if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
222 return;
223 }
224 if let Ok(exe) = std::env::current_exe() {
225 let _ = std::process::Command::new(exe)
226 .arg("--warm")
227 .arg(source)
228 .stdin(std::process::Stdio::null())
229 .stdout(std::process::Stdio::null())
230 .stderr(std::process::Stdio::null())
231 .spawn();
232 }
233}
234
235pub fn run() -> Result<()> {
236 let cli = Cli::parse();
237 crate::logging::init(cli.verbose, cli.quiet);
238
239 if let Some(shell) = cli.completions {
240 let mut cmd = Cli::command();
241 let name = cmd.get_name().to_string();
242 generate(shell, &mut cmd, name, &mut std::io::stdout());
243 return Ok(());
244 }
245
246 if cli.clear_cache {
247 #[cfg(feature = "_semantic")]
248 {
249 let n = crate::semantic::clear_cache();
250 crate::status!("cleared {n} cached vector file(s)");
251 return Ok(());
252 }
253 #[cfg(not(feature = "_semantic"))]
254 anyhow::bail!("no embedding cache in this build (built without --features semantic)");
255 }
256
257 let output = if cli.json {
258 Output::Json
259 } else if cli.ndjson {
260 Output::Ndjson
261 } else {
262 Output::Text
263 };
264
265 let kind: Option<Kind> = match &cli.kind {
266 Some(s) => Some(s.parse()?),
267 None => None,
268 };
269
270 let source = if let Some(s) = cli.source.clone() {
274 s
275 } else if cli.warm {
276 match cli.query.clone() {
277 Some(s) => s,
278 None => load::discover()?,
279 }
280 } else {
281 load::discover()?
282 };
283 let records = load::load(&source)?;
284
285 if cli.warm {
288 #[cfg(feature = "_semantic")]
289 {
290 let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
291 crate::status!("warmed {n} record vector(s) into the cache");
292 return Ok(());
293 }
294 #[cfg(not(feature = "_semantic"))]
295 {
296 let _ = (&cli.model, cli.refresh);
297 anyhow::bail!("--warm needs a semantic build");
298 }
299 }
300
301 let query = cli
303 .query
304 .as_deref()
305 .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
306
307 if cli.resolve {
308 return run_resolve(
309 query,
310 &source,
311 &records,
312 kind,
313 cli.code.as_deref(),
314 cli.limit,
315 output,
316 );
317 }
318
319 let matches: Vec<Match> = if cli.fuzzy {
320 fuzzy_matches(query, &records, kind, cli.limit)
321 } else if cli.semantic {
322 #[cfg(feature = "_semantic")]
323 {
324 semantic_matches(query, &records, kind, &cli)
325 }
326 #[cfg(not(feature = "_semantic"))]
327 {
328 let _ = (&cli.model, cli.refresh);
329 anyhow::bail!(
330 "this build has no semantic search — install it with \
331 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
332 );
333 }
334 } else {
335 let fuzzy = fuzzy_matches(query, &records, kind, cli.limit);
338 #[cfg(feature = "_semantic")]
339 {
340 if crate::semantic::is_cached(&records, cli.model.as_deref()) {
341 let semantic = semantic_matches(query, &records, kind, &cli);
342 combine(fuzzy, semantic, cli.limit)
343 } else {
344 spawn_background_warm(&source);
345 crate::status!(
346 "warming the semantic index in the background — the next run also \
347 ranks by meaning (--semantic to embed now, --fuzzy to skip)"
348 );
349 fuzzy
350 }
351 }
352 #[cfg(not(feature = "_semantic"))]
353 {
354 let _ = (&cli.model, cli.refresh);
355 fuzzy
356 }
357 };
358
359 if matches.is_empty() {
360 crate::status!("no matches for {query:?}");
361 }
362 output.write_matches(&matches)
363}
364
365impl Output {
366 fn write_matches(self, matches: &[Match]) -> Result<()> {
367 #[derive(Serialize)]
368 struct Row<'a> {
369 #[serde(flatten)]
370 record: &'a SchemaRecord,
371 score: f64,
372 }
373 let rows = || {
374 matches.iter().map(|m| Row {
375 record: m.record,
376 score: m.score,
377 })
378 };
379 match self {
380 Output::Json => println!(
381 "{}",
382 serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
383 ),
384 Output::Ndjson => {
385 for row in rows() {
386 println!("{}", serde_json::to_string(&row)?);
387 }
388 }
389 Output::Text => print_text(matches),
390 }
391 Ok(())
392 }
393}
394
395fn print_text(matches: &[Match]) {
396 let width = matches
397 .iter()
398 .map(|m| display_path(m.record).len())
399 .max()
400 .unwrap_or(0)
401 .min(48);
402
403 for m in matches {
404 let r = m.record;
405 let path = display_path(r);
406 let ret = r
407 .type_ref
408 .as_deref()
409 .map(|t| format!(" -> {t}"))
410 .unwrap_or_default();
411 let dep = if r.deprecated.is_some() {
412 " (deprecated)"
413 } else {
414 ""
415 };
416 println!("{path:<width$}{ret} [{kind}]{dep}", kind = r.kind.as_str());
417 }
418}
419
420fn run_resolve(
422 query: &str,
423 source: &str,
424 records: &[SchemaRecord],
425 kind: Option<Kind>,
426 code: Option<&str>,
427 limit: usize,
428 output: Output,
429) -> Result<()> {
430 if code.is_none() {
431 crate::status!("no --code given; resolving against rq's index for the current directory");
432 }
433 let Some(top) = search::search(query, records, kind, 1).into_iter().next() else {
434 anyhow::bail!("no schema entity matches {query:?} to resolve");
435 };
436 crate::status!("resolving {} …", top.record.path);
437 let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
439 .then(|| std::path::Path::new(source))
440 .filter(|p| p.exists());
441 let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
442
443 match output {
444 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
445 Output::Ndjson => {
446 for h in &hits {
447 println!("{}", serde_json::to_string(h)?);
448 }
449 }
450 Output::Text => {
451 if hits.is_empty() {
452 eprintln!(
453 "gqls: no code definition found for {} (tried graphql-ruby rq candidates)",
454 top.record.path
455 );
456 }
457 for h in &hits {
458 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
459 }
460 }
461 }
462 Ok(())
463}
464
465fn display_path(r: &SchemaRecord) -> String {
467 if r.args.is_empty() {
468 r.path.clone()
469 } else {
470 format!("{}({})", r.path, r.args.join(", "))
471 }
472}