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    /// 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    /// Header for URL introspection, `Name: Value` (repeatable) — e.g. an
127    /// `Authorization` token for an auth-gated endpoint.
128    #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
129    header: Vec<String>,
130
131    /// Verbose stderr diagnostics: cache hits, rq candidates, and why the
132    /// embedding model loaded or fell back.
133    #[arg(short, long, conflicts_with = "quiet")]
134    verbose: bool,
135
136    /// Suppress status chatter on stderr (results and hard errors still print).
137    #[arg(short, long)]
138    quiet: bool,
139}
140
141/// The chosen output format — computed once, honored by every mode.
142#[derive(Clone, Copy)]
143enum Output {
144    Text,
145    Json,
146    Ndjson,
147}
148
149/// A ranked result — from either the fuzzy scorer or the semantic ranker, so
150/// both flow through one output path.
151struct Match<'a> {
152    record: &'a SchemaRecord,
153    score: f64,
154}
155
156/// All fuzzy hits above the quality cutoff, best first — the caller truncates
157/// to the display limit, so the length is the true match count. The `bool` is
158/// [`search::has_exact`]: whether some hit matches the query's leaf name
159/// exactly.
160fn 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/// Merge the fuzzy and semantic rankings via Reciprocal Rank Fusion — precise
201/// name matches and meaning matches both surface, and a record strong in both
202/// rises to the top. Fuzzy is weighted a touch higher so an exact-name hit
203/// keeps the lead; scale-free, so the two score systems needn't be normalized.
204#[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    // Key on the record's stable qualified path (unique per entity) rather than
209    // pointer identity, so fusion stays correct even if a ranker ever returned
210    // records not borrowed from the same slice.
211    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/// Spawn a detached `gqls --warm <source>` so the schema's vectors embed in the
234/// background — the next run gets combined fuzzy+semantic results with no wait.
235/// Opt out with `GQLS_NO_AUTOWARM`. Best-effort; failures are ignored.
236#[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    // Single-flight: a detached warm for this source may already be running.
242    // A short-lived lockfile keeps a burst of cold queries from spawning a herd
243    // that all embed the same schema and race the cache.
244    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/// Best-effort single-flight guard for background warming: returns true (and
262/// stakes a claim) when no recent warm for `source` is in flight, false when one
263/// likely is. The lockfile self-expires by mtime, so a crashed warm can't wedge
264/// warming forever, and a failed warm won't be retried in a tight loop.
265#[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    // Lockfiles live in the system temp dir so the OS auto-reaps them.
273    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; // a recent warm is presumably still running
281            }
282        }
283    }
284    let _ = std::fs::create_dir_all(&dir);
285    std::fs::write(&lock, []).is_ok()
286}
287
288/// Parse `-H "Name: Value"` strings into `(name, value)` pairs.
289fn 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    // The schema source. With `--warm` and no explicit source, the sole
336    // positional is the schema (there's no query to warm), so `gqls --warm
337    // schema.graphql` — and the background spawn — target the right file.
338    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    // --warm: embed + cache the schema's vectors, then exit (no query needed).
361    // Also the primitive the background auto-warm spawns.
362    if cli.warm {
363        #[cfg(feature = "_semantic")]
364        {
365            let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
366            crate::status!("cached vectors for {n} record(s)");
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    // clap guarantees a query unless --clear-cache/--completions/--warm.
377    let query = cli
378        .query
379        .as_deref()
380        .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
381
382    // `User name` — a two-word query whose first word exactly names a type —
383    // is the qualified form typed with a space. Rewrite it, but remember the
384    // loose intent: unlike the dot form, an exact hit here keeps the semantic
385    // combine on ("around this", not "exactly this").
386    let spaced = search::spaced_qualifier(query, &records);
387    let loose = spaced.is_some();
388    let query = spaced.as_deref().unwrap_or(query);
389    if loose {
390        crate::detail!("two-word query names a type — searching as {query:?}");
391    }
392
393    // A `Type.field` query whose qualifier names a schema type — exactly, or
394    // as its unique closest misspelling — becomes a hard filter to that type's
395    // members, in every search mode. A silent correction would be confusing,
396    // so that case is announced at normal verbosity.
397    let parent = search::parent_filter(query, &records);
398    if let Some(p) = parent {
399        let (_, qualifier) = search::score::parse_qualified(query);
400        if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
401            crate::detail!("qualifier {p:?} names a type — restricting to its members");
402        } else {
403            crate::status!(
404                "no type named {:?} — using closest match {p:?}",
405                qualifier.unwrap_or_default()
406            );
407        }
408    }
409
410    if cli.resolve {
411        return run_resolve(
412            query,
413            &source,
414            &records,
415            kind,
416            parent,
417            cli.code.as_deref(),
418            cli.limit,
419            output,
420        );
421    }
422
423    // `total` is the fuzzy match count before the display limit, so the footer
424    // can say how much a raised -l would reveal. Semantic-only mode has no
425    // meaningful total (cosine ranks every record), so it never shows one.
426    let t_rank = std::time::Instant::now();
427    let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
428        let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent);
429        let total = fuzzy.len();
430        fuzzy.truncate(cli.limit);
431        (fuzzy, total)
432    } else if cli.semantic {
433        #[cfg(feature = "_semantic")]
434        {
435            let matches = semantic_matches(query, &records, kind, parent, &cli);
436            let total = matches.len();
437            (matches, total)
438        }
439        #[cfg(not(feature = "_semantic"))]
440        {
441            let _ = (&cli.model, cli.refresh);
442            anyhow::bail!(
443                "this build has no semantic search — install it with \
444                 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
445            );
446        }
447    } else {
448        // Default: combine fuzzy + semantic when the cache is warm; when cold,
449        // return fuzzy now and warm the vectors in the background for next time.
450        // An exact name hit skips the combine outright — the query *named* the
451        // entity, so semantic ranking would only append lookalike filler below
452        // it (and cost the model load).
453        let (mut fuzzy, exact) = fuzzy_matches(query, &records, kind, parent);
454        let total = fuzzy.len();
455        fuzzy.truncate(cli.limit);
456        #[cfg(feature = "_semantic")]
457        {
458            if exact && !loose {
459                crate::detail!("exact name match — semantic ranking skipped (--semantic to force)");
460                (fuzzy, total)
461            } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
462                let semantic = semantic_matches(query, &records, kind, parent, &cli);
463                (combine(fuzzy, semantic, cli.limit), total)
464            } else {
465                spawn_background_warm(&source, &cli.header);
466                crate::status!(
467                    "building the semantic index in the background; next run ranks by \
468                     meaning (--semantic to wait, --fuzzy to skip)"
469                );
470                (fuzzy, total)
471            }
472        }
473        #[cfg(not(feature = "_semantic"))]
474        {
475            let _ = (&cli.model, cli.refresh, exact, loose);
476            (fuzzy, total)
477        }
478    };
479
480    crate::detail!("ranked in {:.1?}", t_rank.elapsed());
481
482    if matches.is_empty() {
483        crate::status!("no matches for {query:?}");
484    }
485    output.write_matches(&matches)?;
486    if total > matches.len() {
487        crate::detail!(
488            "{total} matches; showing top {} (-l to adjust)",
489            matches.len()
490        );
491    }
492    Ok(())
493}
494
495impl Output {
496    fn write_matches(self, matches: &[Match]) -> Result<()> {
497        #[derive(Serialize)]
498        struct Row<'a> {
499            #[serde(flatten)]
500            record: &'a SchemaRecord,
501            score: f64,
502        }
503        let rows = || {
504            matches.iter().map(|m| Row {
505                record: m.record,
506                score: m.score,
507            })
508        };
509        match self {
510            Output::Json => println!(
511                "{}",
512                serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
513            ),
514            Output::Ndjson => {
515                for row in rows() {
516                    println!("{}", serde_json::to_string(&row)?);
517                }
518            }
519            Output::Text => print_text(matches),
520        }
521        Ok(())
522    }
523}
524
525fn print_text(matches: &[Match]) {
526    let width = matches
527        .iter()
528        .map(|m| display_path(m.record).len())
529        .max()
530        .unwrap_or(0)
531        .min(48);
532
533    for m in matches {
534        let r = m.record;
535        let path = display_path(r);
536        let ret = r
537            .type_ref
538            .as_deref()
539            .map(|t| format!(" -> {t}"))
540            .unwrap_or_default();
541        let dep = if r.deprecated.is_some() {
542            " (deprecated)"
543        } else {
544            ""
545        };
546        println!("{path:<width$}{ret}  [{kind}]{dep}", kind = r.kind.as_str());
547    }
548}
549
550/// Fuzzy-find the field, then hand it to rq to locate its resolver in code.
551#[allow(clippy::too_many_arguments)]
552fn run_resolve(
553    query: &str,
554    source: &str,
555    records: &[SchemaRecord],
556    kind: Option<Kind>,
557    parent: Option<&str>,
558    code: Option<&str>,
559    limit: usize,
560    output: Output,
561) -> Result<()> {
562    if code.is_none() {
563        crate::status!("searching code in the current directory (--code to search elsewhere)");
564    }
565    let Some(top) = search::search(query, records, kind, parent)
566        .into_iter()
567        .next()
568    else {
569        anyhow::bail!("no schema entity matches {query:?} to resolve");
570    };
571    crate::status!("resolving {} …", top.record.path);
572    // a local file schema (not a URL) enables package-proximity ranking
573    let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
574        .then(|| std::path::Path::new(source))
575        .filter(|p| p.exists());
576    let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
577
578    match output {
579        Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
580        Output::Ndjson => {
581            for h in &hits {
582                println!("{}", serde_json::to_string(h)?);
583            }
584        }
585        Output::Text => {
586            if hits.is_empty() {
587                crate::status!(
588                    "no code definition found for {} (-v shows what was tried)",
589                    top.record.path
590                );
591            }
592            for h in &hits {
593                println!("{}:{}  {}  (via {})", h.file, h.line, h.name, h.via);
594            }
595        }
596    }
597    Ok(())
598}
599
600/// `Query.user(id: ID!, first: Int)` — path plus a compact arg signature.
601fn display_path(r: &SchemaRecord) -> String {
602    if r.args.is_empty() {
603        r.path.clone()
604    } else {
605        format!("{}({})", r.path, r.args.join(", "))
606    }
607}