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 (--semantic, --model, --refresh, --clear-cache) are
13/// hidden 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 (--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    /// 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    /// Omit schema descriptions from text output (they're shown by default;
86    /// `--json`/`--ndjson` always carry the full text).
87    #[arg(short = 'D', long)]
88    no_description: bool,
89
90    /// Force semantic-only search. By default fuzzy and semantic results are
91    /// combined once the schema's vectors are cached.
92    #[arg(long, hide = HIDE_SEMANTIC)]
93    semantic: bool,
94
95    /// Force fuzzy-only search — skip the semantic combine.
96    #[arg(long, conflicts_with = "semantic")]
97    fuzzy: bool,
98
99    /// Embedding model for --semantic: a local dir / `.onnx` path, or a
100    /// HuggingFace `org/name` id. Defaults to all-MiniLM-L6-v2.
101    #[arg(long, hide = HIDE_SEMANTIC)]
102    model: Option<String>,
103
104    /// Force a re-embed for --semantic, overwriting the cache. Schema edits
105    /// already re-embed on their own; use this for changes the cache can't see
106    /// (e.g. a new model).
107    #[arg(long, hide = HIDE_SEMANTIC)]
108    refresh: bool,
109
110    /// Delete all cached embedding vector files, then exit.
111    #[arg(long, hide = HIDE_SEMANTIC)]
112    clear_cache: bool,
113
114    /// Pre-embed the schema's vectors (warm the cache), then exit.
115    #[arg(long, hide = HIDE_SEMANTIC)]
116    warm: bool,
117
118    /// Print a shell completion script (bash, zsh, fish, ...) to stdout, then exit.
119    #[arg(long, value_name = "SHELL")]
120    completions: Option<Shell>,
121
122    /// Jump the top match to its graphql-ruby resolver/method in code, via
123    /// `rq` (must be installed).
124    #[arg(short = 'R', long)]
125    resolve: bool,
126
127    /// Directory of the server code for --resolve (defaults to rq's index).
128    #[arg(long)]
129    code: Option<String>,
130
131    /// Header for URL introspection, `Name: Value` (repeatable) — e.g. an
132    /// `Authorization` token for an auth-gated endpoint.
133    #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
134    header: Vec<String>,
135
136    /// Verbose stderr diagnostics: cache hits, rq candidates, and why the
137    /// embedding model loaded or fell back.
138    #[arg(short, long, conflicts_with = "quiet")]
139    verbose: bool,
140
141    /// Suppress status chatter on stderr (results and hard errors still print).
142    #[arg(short, long)]
143    quiet: bool,
144}
145
146/// The chosen output format — computed once, honored by every mode. Text
147/// carries whether to print descriptions; the JSON forms always include them.
148#[derive(Clone, Copy)]
149enum Output {
150    Text { descriptions: bool },
151    Json,
152    Ndjson,
153}
154
155/// A ranked result — from either the fuzzy scorer or the semantic ranker, so
156/// both flow through one output path.
157struct Match<'a> {
158    record: &'a SchemaRecord,
159    score: f64,
160}
161
162/// All fuzzy hits above the quality cutoff, best first — the caller truncates
163/// to the display limit, so the length is the true match count. The `bool` is
164/// [`search::named_hit`]: whether some hit names the query's leaf.
165fn fuzzy_matches<'a>(
166    query: &str,
167    records: &'a [SchemaRecord],
168    kind: Option<Kind>,
169    parent: Option<&str>,
170) -> (Vec<Match<'a>>, bool) {
171    let hits = search::search(query, records, kind, parent);
172    let named = search::named_hit(query, &hits);
173    let matches = hits
174        .into_iter()
175        .map(|h| Match {
176            record: h.record,
177            score: h.score as f64,
178        })
179        .collect();
180    (matches, named)
181}
182
183#[cfg(feature = "_semantic")]
184fn semantic_matches<'a>(
185    query: &str,
186    records: &'a [SchemaRecord],
187    kind: Option<Kind>,
188    parent: Option<&str>,
189    cli: &Cli,
190) -> Vec<Match<'a>> {
191    crate::semantic::search(
192        query,
193        records,
194        kind,
195        parent,
196        cli.limit,
197        cli.model.as_deref(),
198        cli.refresh,
199    )
200    .into_iter()
201    .map(|(score, record)| Match { record, score })
202    .collect()
203}
204
205/// Merge the fuzzy and semantic rankings via Reciprocal Rank Fusion — precise
206/// name matches and meaning matches both surface, and a record strong in both
207/// rises to the top. Fuzzy is weighted a touch higher so an exact-name hit
208/// keeps the lead; scale-free, so the two score systems needn't be normalized.
209#[cfg(feature = "_semantic")]
210fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
211    use std::collections::HashMap;
212    const K: f64 = 60.0;
213    // Key on the record's stable qualified path (unique per entity) rather than
214    // pointer identity, so fusion stays correct even if a ranker ever returned
215    // records not borrowed from the same slice.
216    let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
217    for (rank, m) in fuzzy.iter().enumerate() {
218        scored
219            .entry(m.record.path.as_str())
220            .or_insert((0.0, m.record))
221            .0 += 1.0 / (K + rank as f64 + 1.0);
222    }
223    for (rank, m) in semantic.iter().enumerate() {
224        scored
225            .entry(m.record.path.as_str())
226            .or_insert((0.0, m.record))
227            .0 += 0.7 / (K + rank as f64 + 1.0);
228    }
229    let mut merged: Vec<Match> = scored
230        .into_values()
231        .map(|(score, record)| Match { record, score })
232        .collect();
233    merged.sort_by(|a, b| b.score.total_cmp(&a.score));
234    merged.truncate(limit);
235    merged
236}
237
238/// Spawn a detached `gqls --warm <source>` so the schema's vectors embed in the
239/// background — the next run gets combined fuzzy+semantic results with no wait.
240/// Opt out with `GQLS_NO_AUTOWARM`. Best-effort; failures are ignored.
241#[cfg(feature = "_semantic")]
242fn spawn_background_warm(source: &str, headers: &[String]) {
243    if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
244        return;
245    }
246    // Single-flight: a detached warm for this source may already be running.
247    // A short-lived lockfile keeps a burst of cold queries from spawning a herd
248    // that all embed the same schema and race the cache.
249    if !claim_warm_lock(source) {
250        return;
251    }
252    if let Ok(exe) = std::env::current_exe() {
253        let mut cmd = std::process::Command::new(exe);
254        cmd.arg("--warm").arg(source);
255        for h in headers {
256            cmd.arg("--header").arg(h);
257        }
258        let _ = cmd
259            .stdin(std::process::Stdio::null())
260            .stdout(std::process::Stdio::null())
261            .stderr(std::process::Stdio::null())
262            .spawn();
263    }
264}
265
266/// Best-effort single-flight guard for background warming: returns true (and
267/// stakes a claim) when no recent warm for `source` is in flight, false when one
268/// likely is. The lockfile self-expires by mtime, so a crashed warm can't wedge
269/// warming forever, and a failed warm won't be retried in a tight loop.
270#[cfg(feature = "_semantic")]
271fn claim_warm_lock(source: &str) -> bool {
272    use std::collections::hash_map::DefaultHasher;
273    use std::hash::{Hash, Hasher};
274    use std::time::Duration;
275
276    const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
277    // Lockfiles live in the system temp dir so the OS auto-reaps them.
278    let dir = crate::paths::temp_dir();
279    let mut h = DefaultHasher::new();
280    source.hash(&mut h);
281    let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
282    if let Ok(meta) = std::fs::metadata(&lock) {
283        if let Ok(modified) = meta.modified() {
284            if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
285                return false; // a recent warm is presumably still running
286            }
287        }
288    }
289    let _ = std::fs::create_dir_all(&dir);
290    std::fs::write(&lock, []).is_ok()
291}
292
293/// Parse `-H "Name: Value"` strings into `(name, value)` pairs.
294fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
295    raw.iter()
296        .map(|h| {
297            let (name, value) = h
298                .split_once(':')
299                .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
300            Ok((name.trim().to_string(), value.trim().to_string()))
301        })
302        .collect()
303}
304
305pub fn run() -> Result<()> {
306    let cli = Cli::parse();
307    crate::logging::init(cli.verbose, cli.quiet);
308
309    if let Some(shell) = cli.completions {
310        let mut cmd = Cli::command();
311        let name = cmd.get_name().to_string();
312        generate(shell, &mut cmd, name, &mut std::io::stdout());
313        return Ok(());
314    }
315
316    if cli.clear_cache {
317        let introspect = crate::load::introspect::clear_cache();
318        let records = crate::load::record_cache::clear();
319        #[cfg(feature = "_semantic")]
320        let vectors = crate::semantic::clear_cache();
321        #[cfg(not(feature = "_semantic"))]
322        let vectors = 0;
323        crate::status!("cleared {} cached file(s)", introspect + records + vectors);
324        return Ok(());
325    }
326
327    let output = if cli.json {
328        Output::Json
329    } else if cli.ndjson {
330        Output::Ndjson
331    } else {
332        Output::Text {
333            descriptions: !cli.no_description,
334        }
335    };
336
337    let kind: Option<Kind> = match &cli.kind {
338        Some(s) => Some(s.parse()?),
339        None => None,
340    };
341
342    // The schema source. With `--warm` and no explicit source, the sole
343    // positional is the schema (there's no query to warm), so `gqls --warm
344    // schema.graphql` — and the background spawn — target the right file.
345    let source = if let Some(s) = cli.source.clone() {
346        s
347    } else if cli.warm {
348        match cli.query.clone() {
349            Some(s) => s,
350            None => load::discover()?,
351        }
352    } else {
353        load::discover()?
354    };
355    let load_opts = load::LoadOptions {
356        headers: parse_headers(&cli.header)?,
357        refresh: cli.refresh,
358    };
359    let t_load = std::time::Instant::now();
360    let records = load::load(&source, &load_opts)?;
361    crate::detail!(
362        "loaded {} records in {:.1?}",
363        records.len(),
364        t_load.elapsed()
365    );
366
367    // --warm: embed + cache the schema's vectors, then exit (no query needed).
368    // Also the primitive the background auto-warm spawns.
369    if cli.warm {
370        #[cfg(feature = "_semantic")]
371        {
372            let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
373            crate::status!("cached vectors for {n} record(s)");
374            return Ok(());
375        }
376        #[cfg(not(feature = "_semantic"))]
377        {
378            let _ = (&cli.model, cli.refresh);
379            anyhow::bail!("--warm needs a semantic build");
380        }
381    }
382
383    // clap guarantees a query unless --clear-cache/--completions/--warm.
384    let query = cli
385        .query
386        .as_deref()
387        .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
388
389    // `User name` — a two-word query whose first word exactly names a type —
390    // is the qualified form typed with a space. Rewrite it, but remember the
391    // loose intent: unlike the dot form, an exact hit here keeps the semantic
392    // combine on ("around this", not "exactly this").
393    let spaced = search::spaced_qualifier(query, &records);
394    let loose = spaced.is_some();
395    let query = spaced.as_deref().unwrap_or(query);
396    if loose {
397        crate::detail!("two-word query names a type — searching as {query:?}");
398    }
399
400    // A `Type.field` query whose qualifier names a schema type — exactly, or
401    // as its unique closest misspelling — becomes a hard filter to that type's
402    // members, in every search mode. A silent correction would be confusing,
403    // so that case is announced at normal verbosity.
404    let parent = search::parent_filter(query, &records);
405    if let Some(p) = parent {
406        let (_, qualifier) = search::score::parse_qualified(query);
407        if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
408            crate::detail!("qualifier {p:?} names a type — restricting to its members");
409        } else {
410            crate::status!(
411                "no type named {:?} — using closest match {p:?}",
412                qualifier.unwrap_or_default()
413            );
414        }
415    }
416
417    if cli.resolve {
418        return run_resolve(
419            query,
420            &source,
421            &records,
422            kind,
423            parent,
424            cli.code.as_deref(),
425            cli.limit,
426            output,
427        );
428    }
429
430    // `total` is the fuzzy match count before the display limit, so the footer
431    // can say how much a raised -l would reveal. Semantic-only mode has no
432    // meaningful total (cosine ranks every record), so it never shows one.
433    let t_rank = std::time::Instant::now();
434    let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
435        let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent);
436        let total = fuzzy.len();
437        fuzzy.truncate(cli.limit);
438        (fuzzy, total)
439    } else if cli.semantic {
440        #[cfg(feature = "_semantic")]
441        {
442            let matches = semantic_matches(query, &records, kind, parent, &cli);
443            let total = matches.len();
444            (matches, total)
445        }
446        #[cfg(not(feature = "_semantic"))]
447        {
448            let _ = (&cli.model, cli.refresh);
449            anyhow::bail!(
450                "this build has no semantic search — install it with \
451                 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
452            );
453        }
454    } else {
455        // Default: combine fuzzy + semantic when the cache is warm; when cold,
456        // return fuzzy now and warm the vectors in the background for next time.
457        // A strong name hit (exact, or the leaf whole at a word boundary —
458        // `name` → `lastName`) skips the combine outright: the user typed a
459        // word that exists, so semantic ranking would only append lookalike
460        // filler below it (and cost the model load).
461        let (mut fuzzy, named) = fuzzy_matches(query, &records, kind, parent);
462        let total = fuzzy.len();
463        fuzzy.truncate(cli.limit);
464        #[cfg(feature = "_semantic")]
465        {
466            if named && !loose {
467                crate::detail!(
468                    "strong name match — semantic ranking skipped (--semantic to force)"
469                );
470                (fuzzy, total)
471            } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
472                let semantic = semantic_matches(query, &records, kind, parent, &cli);
473                (combine(fuzzy, semantic, cli.limit), total)
474            } else {
475                spawn_background_warm(&source, &cli.header);
476                crate::status!(
477                    "building the semantic index in the background; next run ranks by \
478                     meaning (--semantic to wait, --fuzzy to skip)"
479                );
480                (fuzzy, total)
481            }
482        }
483        #[cfg(not(feature = "_semantic"))]
484        {
485            let _ = (&cli.model, cli.refresh, named, loose);
486            (fuzzy, total)
487        }
488    };
489
490    crate::detail!("ranked in {:.1?}", t_rank.elapsed());
491
492    if matches.is_empty() {
493        crate::status!("no matches for {query:?}");
494    }
495    output.write_matches(&matches)?;
496    if total > matches.len() {
497        crate::detail!(
498            "{total} matches; showing top {} (-l to adjust)",
499            matches.len()
500        );
501    }
502    Ok(())
503}
504
505impl Output {
506    fn write_matches(self, matches: &[Match]) -> Result<()> {
507        #[derive(Serialize)]
508        struct Row<'a> {
509            #[serde(flatten)]
510            record: &'a SchemaRecord,
511            score: f64,
512        }
513        let rows = || {
514            matches.iter().map(|m| Row {
515                record: m.record,
516                score: m.score,
517            })
518        };
519        match self {
520            Output::Json => println!(
521                "{}",
522                serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
523            ),
524            Output::Ndjson => {
525                for row in rows() {
526                    println!("{}", serde_json::to_string(&row)?);
527                }
528            }
529            Output::Text { descriptions } => print_text(matches, descriptions),
530        }
531        Ok(())
532    }
533}
534
535/// Longest description rendered inline. A schema doc can run to paragraphs;
536/// one line per result keeps the output greppable, so the rest is elided
537/// (`--json` carries the full text).
538const DESCRIPTION_WIDTH: usize = 72;
539
540fn print_text(matches: &[Match], descriptions: bool) {
541    let width = matches
542        .iter()
543        .map(|m| display_path(m.record).len())
544        .max()
545        .unwrap_or(0)
546        .min(48);
547
548    for m in matches {
549        let r = m.record;
550        let path = display_path(r);
551        let ret = r
552            .type_ref
553            .as_deref()
554            .map(|t| format!(" -> {t}"))
555            .unwrap_or_default();
556        let dep = if r.deprecated.is_some() {
557            " (deprecated)"
558        } else {
559            ""
560        };
561        let desc = if descriptions {
562            r.description
563                .as_deref()
564                .map(summarize)
565                .filter(|d| !d.is_empty())
566                .map(|d| format!(" — {d}"))
567                .unwrap_or_default()
568        } else {
569            String::new()
570        };
571        println!(
572            "{path:<width$}{ret}  [{kind}]{dep}{desc}",
573            kind = r.kind.as_str()
574        );
575    }
576}
577
578/// A schema description as one line: whitespace (including the newlines of a
579/// block description) collapsed, then elided at [`DESCRIPTION_WIDTH`].
580fn summarize(description: &str) -> String {
581    let mut out = String::new();
582    for (i, word) in description.split_whitespace().enumerate() {
583        if i > 0 {
584            out.push(' ');
585        }
586        out.push_str(word);
587        if out.chars().count() > DESCRIPTION_WIDTH {
588            let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
589            return format!("{}…", kept.trim_end());
590        }
591    }
592    out
593}
594
595/// Fuzzy-find the field, then hand it to rq to locate its resolver in code.
596#[allow(clippy::too_many_arguments)]
597fn run_resolve(
598    query: &str,
599    source: &str,
600    records: &[SchemaRecord],
601    kind: Option<Kind>,
602    parent: Option<&str>,
603    code: Option<&str>,
604    limit: usize,
605    output: Output,
606) -> Result<()> {
607    if code.is_none() {
608        crate::status!("searching code in the current directory (--code to search elsewhere)");
609    }
610    let Some(top) = search::search(query, records, kind, parent)
611        .into_iter()
612        .next()
613    else {
614        anyhow::bail!("no schema entity matches {query:?} to resolve");
615    };
616    crate::status!("resolving {} …", top.record.path);
617    // a local file schema (not a URL) enables package-proximity ranking
618    let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
619        .then(|| std::path::Path::new(source))
620        .filter(|p| p.exists());
621    let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
622
623    match output {
624        Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
625        Output::Ndjson => {
626            for h in &hits {
627                println!("{}", serde_json::to_string(h)?);
628            }
629        }
630        Output::Text { .. } => {
631            if hits.is_empty() {
632                crate::status!(
633                    "no code definition found for {} (-v shows what was tried)",
634                    top.record.path
635                );
636            }
637            for h in &hits {
638                println!("{}:{}  {}  (via {})", h.file, h.line, h.name, h.via);
639            }
640        }
641    }
642    Ok(())
643}
644
645/// `Query.user(id: ID!, first: Int)` — path plus a compact arg signature.
646fn display_path(r: &SchemaRecord) -> String {
647    if r.args.is_empty() {
648        r.path.clone()
649    } else {
650        format!("{}({})", r.path, r.args.join(", "))
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::{summarize, DESCRIPTION_WIDTH};
657
658    #[test]
659    fn collapses_block_descriptions_to_one_line() {
660        assert_eq!(summarize("  Look up\n  a user.\n"), "Look up a user.");
661        assert_eq!(summarize("   "), "");
662    }
663
664    #[test]
665    fn elides_past_the_width() {
666        let long = "word ".repeat(60);
667        let out = summarize(&long);
668        assert!(out.ends_with('…'), "{out}");
669        assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
670    }
671
672    #[test]
673    fn keeps_a_description_that_fits() {
674        let s = "An account.";
675        assert_eq!(summarize(s), s);
676    }
677}