Skip to main content

gqls/
cli.rs

1//! clap CLI, dispatch, and output formatting (text / json / ndjson).
2
3use 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
12/// The semantic-only flags (-s, --model, --refresh, --clear-cache) are hidden
13/// from --help on builds without the feature, where they'd only error.
14const 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 (-s, 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    /// Search query. Fuzzy by default; abbreviations like `usr` match `User`,
60    /// and `Type.field` queries match against the qualified path.
61    #[arg(required_unless_present_any = ["clear_cache", "completions", "warm"])]
62    query: Option<String>,
63
64    /// Schema source: a `.graphql`/`.graphqls` SDL file, a `.json` introspection
65    /// dump, or an http(s) URL (introspected live). If omitted, gqls searches
66    /// the current directory tree for a schema.
67    source: Option<String>,
68
69    /// Restrict to a kind (object, field, query, mutation, enum, scalar, ...).
70    #[arg(short, long)]
71    kind: Option<String>,
72
73    /// Maximum number of results.
74    #[arg(short, long, default_value_t = 20)]
75    limit: usize,
76
77    /// Pretty JSON array.
78    #[arg(short, long, conflicts_with = "ndjson")]
79    json: bool,
80
81    /// Newline-delimited JSON (one record per line).
82    #[arg(short = 'J', long)]
83    ndjson: bool,
84
85    /// Force semantic-only search. By default fuzzy and semantic results are
86    /// combined once the schema's vectors are cached.
87    #[arg(long, hide = HIDE_SEMANTIC)]
88    semantic: bool,
89
90    /// Force fuzzy-only search — skip the semantic combine.
91    #[arg(long, conflicts_with = "semantic")]
92    fuzzy: bool,
93
94    /// Embedding model for --semantic: a local dir / `.onnx` path, or a
95    /// HuggingFace `org/name` id. Defaults to all-MiniLM-L6-v2.
96    #[arg(long, hide = HIDE_SEMANTIC)]
97    model: Option<String>,
98
99    /// Force a re-embed for --semantic, overwriting the cache. Schema edits
100    /// already re-embed on their own; use this for changes the cache can't see
101    /// (e.g. a new model).
102    #[arg(long, hide = HIDE_SEMANTIC)]
103    refresh: bool,
104
105    /// Delete all cached embedding vector files, then exit.
106    #[arg(long, hide = HIDE_SEMANTIC)]
107    clear_cache: bool,
108
109    /// Pre-embed the schema's vectors (warm the cache), then exit.
110    #[arg(long, hide = HIDE_SEMANTIC)]
111    warm: bool,
112
113    /// Print a shell completion script (bash, zsh, fish, ...) to stdout, then exit.
114    #[arg(long, value_name = "SHELL")]
115    completions: Option<Shell>,
116
117    /// Jump the top match to its graphql-ruby resolver/method in code, via
118    /// `rq` (must be installed).
119    #[arg(short = 'R', long)]
120    resolve: bool,
121
122    /// Directory of the server code for --resolve (defaults to rq's index).
123    #[arg(long)]
124    code: Option<String>,
125}
126
127/// The chosen output format — computed once, honored by every mode.
128#[derive(Clone, Copy)]
129enum Output {
130    Text,
131    Json,
132    Ndjson,
133}
134
135/// A ranked result — from either the fuzzy scorer or the semantic ranker, so
136/// both flow through one output path.
137struct Match<'a> {
138    record: &'a SchemaRecord,
139    score: f64,
140}
141
142fn fuzzy_matches<'a>(
143    query: &str,
144    records: &'a [SchemaRecord],
145    kind: Option<Kind>,
146    limit: usize,
147) -> Vec<Match<'a>> {
148    search::search(query, records, kind, limit)
149        .into_iter()
150        .map(|h| Match {
151            record: h.record,
152            score: h.score as f64,
153        })
154        .collect()
155}
156
157#[cfg(feature = "_semantic")]
158fn semantic_matches<'a>(
159    query: &str,
160    records: &'a [SchemaRecord],
161    kind: Option<Kind>,
162    cli: &Cli,
163) -> Vec<Match<'a>> {
164    crate::semantic::search(query, records, kind, cli.limit, cli.model.as_deref(), cli.refresh)
165        .into_iter()
166        .map(|(score, record)| Match { record, score })
167        .collect()
168}
169
170/// Merge the fuzzy and semantic rankings via Reciprocal Rank Fusion — precise
171/// name matches and meaning matches both surface, and a record strong in both
172/// rises to the top. Fuzzy is weighted a touch higher so an exact-name hit
173/// keeps the lead; scale-free, so the two score systems needn't be normalized.
174#[cfg(feature = "_semantic")]
175fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
176    use std::collections::HashMap;
177    const K: f64 = 60.0;
178    let mut scored: HashMap<*const SchemaRecord, (f64, &SchemaRecord)> = HashMap::new();
179    for (rank, m) in fuzzy.iter().enumerate() {
180        scored.entry(m.record as *const _).or_insert((0.0, m.record)).0 += 1.0 / (K + rank as f64 + 1.0);
181    }
182    for (rank, m) in semantic.iter().enumerate() {
183        scored.entry(m.record as *const _).or_insert((0.0, m.record)).0 += 0.7 / (K + rank as f64 + 1.0);
184    }
185    let mut merged: Vec<Match> = scored
186        .into_values()
187        .map(|(score, record)| Match { record, score })
188        .collect();
189    merged.sort_by(|a, b| b.score.total_cmp(&a.score));
190    merged.truncate(limit);
191    merged
192}
193
194/// Spawn a detached `gqls --warm <source>` so the schema's vectors embed in the
195/// background — the next run gets combined fuzzy+semantic results with no wait.
196/// Opt out with `GQLS_NO_AUTOWARM`. Best-effort; failures are ignored.
197#[cfg(feature = "_semantic")]
198fn spawn_background_warm(source: &str) {
199    if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
200        return;
201    }
202    if let Ok(exe) = std::env::current_exe() {
203        let _ = std::process::Command::new(exe)
204            .arg("--warm")
205            .arg(source)
206            .stdin(std::process::Stdio::null())
207            .stdout(std::process::Stdio::null())
208            .stderr(std::process::Stdio::null())
209            .spawn();
210    }
211}
212
213pub fn run() -> Result<()> {
214    let cli = Cli::parse();
215
216    if let Some(shell) = cli.completions {
217        let mut cmd = Cli::command();
218        let name = cmd.get_name().to_string();
219        generate(shell, &mut cmd, name, &mut std::io::stdout());
220        return Ok(());
221    }
222
223    if cli.clear_cache {
224        #[cfg(feature = "_semantic")]
225        {
226            let n = crate::semantic::clear_cache();
227            eprintln!("gqls: cleared {n} cached vector file(s)");
228            return Ok(());
229        }
230        #[cfg(not(feature = "_semantic"))]
231        anyhow::bail!("no embedding cache in this build (built without --features semantic)");
232    }
233
234    let output = if cli.json {
235        Output::Json
236    } else if cli.ndjson {
237        Output::Ndjson
238    } else {
239        Output::Text
240    };
241
242    let kind: Option<Kind> = match &cli.kind {
243        Some(s) => Some(s.parse()?),
244        None => None,
245    };
246
247    // The schema source. With `--warm` and no explicit source, the sole
248    // positional is the schema (there's no query to warm), so `gqls --warm
249    // schema.graphql` — and the background spawn — target the right file.
250    let source = if let Some(s) = cli.source.clone() {
251        s
252    } else if cli.warm {
253        match cli.query.clone() {
254            Some(s) => s,
255            None => load::discover()?,
256        }
257    } else {
258        load::discover()?
259    };
260    let records = load::load(&source)?;
261
262    // --warm: embed + cache the schema's vectors, then exit (no query needed).
263    // Also the primitive the background auto-warm spawns.
264    if cli.warm {
265        #[cfg(feature = "_semantic")]
266        {
267            let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
268            eprintln!("gqls: warmed {n} record vector(s) into the cache");
269            return Ok(());
270        }
271        #[cfg(not(feature = "_semantic"))]
272        {
273            let _ = (&cli.model, cli.refresh);
274            anyhow::bail!("--warm needs a semantic build");
275        }
276    }
277
278    // clap guarantees a query unless --clear-cache/--completions/--warm.
279    let query = cli
280        .query
281        .as_deref()
282        .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
283
284    if cli.resolve {
285        return run_resolve(query, &source, &records, kind, cli.code.as_deref(), cli.limit, output);
286    }
287
288    let matches: Vec<Match> = if cli.fuzzy {
289        fuzzy_matches(query, &records, kind, cli.limit)
290    } else if cli.semantic {
291        #[cfg(feature = "_semantic")]
292        {
293            semantic_matches(query, &records, kind, &cli)
294        }
295        #[cfg(not(feature = "_semantic"))]
296        {
297            let _ = (&cli.model, cli.refresh);
298            anyhow::bail!(
299                "this build has no semantic search — install it with \
300                 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
301            );
302        }
303    } else {
304        // Default: combine fuzzy + semantic when the cache is warm; when cold,
305        // return fuzzy now and warm the vectors in the background for next time.
306        let fuzzy = fuzzy_matches(query, &records, kind, cli.limit);
307        #[cfg(feature = "_semantic")]
308        {
309            if crate::semantic::is_cached(&records, cli.model.as_deref()) {
310                let semantic = semantic_matches(query, &records, kind, &cli);
311                combine(fuzzy, semantic, cli.limit)
312            } else {
313                spawn_background_warm(&source);
314                eprintln!(
315                    "gqls: warming the semantic index in the background — the next run also \
316                     ranks by meaning (--semantic to embed now, --fuzzy to skip)"
317                );
318                fuzzy
319            }
320        }
321        #[cfg(not(feature = "_semantic"))]
322        {
323            let _ = (&cli.model, cli.refresh);
324            fuzzy
325        }
326    };
327
328    if matches.is_empty() {
329        eprintln!("gqls: no matches for {query:?}");
330    }
331    output.write_matches(&matches)
332}
333
334impl Output {
335    fn write_matches(self, matches: &[Match]) -> Result<()> {
336        #[derive(Serialize)]
337        struct Row<'a> {
338            #[serde(flatten)]
339            record: &'a SchemaRecord,
340            score: f64,
341        }
342        let rows = || {
343            matches.iter().map(|m| Row {
344                record: m.record,
345                score: m.score,
346            })
347        };
348        match self {
349            Output::Json => println!("{}", serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?),
350            Output::Ndjson => {
351                for row in rows() {
352                    println!("{}", serde_json::to_string(&row)?);
353                }
354            }
355            Output::Text => print_text(matches),
356        }
357        Ok(())
358    }
359}
360
361fn print_text(matches: &[Match]) {
362    let width = matches
363        .iter()
364        .map(|m| display_path(m.record).len())
365        .max()
366        .unwrap_or(0)
367        .min(48);
368
369    for m in matches {
370        let r = m.record;
371        let path = display_path(r);
372        let ret = r
373            .type_ref
374            .as_deref()
375            .map(|t| format!(" -> {t}"))
376            .unwrap_or_default();
377        let dep = if r.deprecated.is_some() {
378            " (deprecated)"
379        } else {
380            ""
381        };
382        println!("{path:<width$}{ret}  [{kind}]{dep}", kind = r.kind.as_str());
383    }
384}
385
386/// Fuzzy-find the field, then hand it to rq to locate its resolver in code.
387fn run_resolve(
388    query: &str,
389    source: &str,
390    records: &[SchemaRecord],
391    kind: Option<Kind>,
392    code: Option<&str>,
393    limit: usize,
394    output: Output,
395) -> Result<()> {
396    if code.is_none() {
397        eprintln!(
398            "gqls: no --code given; resolving against rq's index for the current directory"
399        );
400    }
401    let Some(top) = search::search(query, records, kind, 1).into_iter().next() else {
402        anyhow::bail!("no schema entity matches {query:?} to resolve");
403    };
404    eprintln!("gqls: resolving {} …", top.record.path);
405    // a local file schema (not a URL) enables package-proximity ranking
406    let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
407        .then(|| std::path::Path::new(source))
408        .filter(|p| p.exists());
409    let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
410
411    match output {
412        Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
413        Output::Ndjson => {
414            for h in &hits {
415                println!("{}", serde_json::to_string(h)?);
416            }
417        }
418        Output::Text => {
419            if hits.is_empty() {
420                eprintln!(
421                    "gqls: no code definition found for {} (tried graphql-ruby rq candidates)",
422                    top.record.path
423                );
424            }
425            for h in &hits {
426                println!("{}:{}  {}  (via {})", h.file, h.line, h.name, h.via);
427            }
428        }
429    }
430    Ok(())
431}
432
433/// `Query.user(id: ID!, first: Int)` — path plus a compact arg signature.
434fn display_path(r: &SchemaRecord) -> String {
435    if r.args.is_empty() {
436        r.path.clone()
437    } else {
438        format!("{}({})", r.path, r.args.join(", "))
439    }
440}