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 User.                          list a type's fields (or 'User.*')
23  gqls 'User.{first,last}Name'        also ? for one char, {a,b} to alternate
24  gqls --returns Company -k query     find fields by return type, not name
25  gqls repo schema.json               search a local introspection dump
26  gqls repo https://api/graphql       introspect a live endpoint
27  gqls 'cancel a subscription'        rank by meaning (fuzzy + semantic, auto)
28  gqls Mutation.createUser -e         draft an operation to paste
29  gqls Query.user -R --code ./app     jump to the graphql-ruby resolver
30  gqls user schema.graphql -j         JSON output (-J for ndjson)
31";
32
33#[cfg(not(feature = "_semantic"))]
34const EXAMPLES: &str = "\
35EXAMPLES:
36  gqls user schema.graphql            fuzzy search an SDL file
37  gqls createUser -k mutation         restrict to a kind (schema auto-discovered)
38  gqls User.email                     qualified Type.field query
39  gqls User.                          list a type's fields (or 'User.*')
40  gqls 'User.{first,last}Name'        also ? for one char, {a,b} to alternate
41  gqls --returns Company -k query     find fields by return type, not name
42  gqls repo schema.json               search a local introspection dump
43  gqls repo https://api/graphql       introspect a live endpoint
44  gqls Mutation.createUser -e         draft an operation to paste
45  gqls Query.user -R --code ./app     jump to the graphql-ruby resolver
46  gqls user schema.graphql -j         JSON output (-J for ndjson)
47
48Semantic search (--semantic, rank by meaning) is not compiled into this build. Enable it:
49  cargo install gqls-cli --features semantic
50  brew install dpep/tools/gqls
51";
52
53#[derive(Parser)]
54#[command(
55    name = "gqls",
56    version,
57    about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
58    long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
59                  terminal. The source is an SDL file, a local introspection JSON dump, or a live \
60                  http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
61                  Fuzzy and semantic results are ranked together by default (--semantic or \
62                  --fuzzy forces one); --resolve jumps to the graphql-ruby resolver via rq. All modes \
63                  support -j/--json and -J/--ndjson.",
64    after_help = EXAMPLES
65)]
66struct Cli {
67    /// Search query. Fuzzy by default; abbreviations like `usr` match `User`,
68    /// and `Type.field` queries match against the qualified path. A trailing
69    /// dot lists a type's fields (`User.`); the general wildcards (`*` any
70    /// run, `?` one char, `{a,b}` alternatives) enumerate too, but quote them
71    /// so the shell doesn't expand them first.
72    #[arg(required_unless_present_any = ["clear_cache", "completions", "warm", "returns"])]
73    query: Option<String>,
74
75    /// Schema source: a `.graphql`/`.graphqls` SDL file, a `.json` introspection
76    /// dump, or an http(s) URL (introspected live). If omitted, gqls searches
77    /// the current directory tree for a schema.
78    source: Option<String>,
79
80    /// Restrict to a kind (object, field, query, mutation, enum, scalar, ...).
81    #[arg(short, long)]
82    kind: Option<String>,
83
84    /// Restrict to fields returning this type, ignoring `[]`/`!` wrappers —
85    /// `--returns Company` finds `myEmployer: Company`. Wildcards work
86    /// (`--returns '*Payload'`). With no QUERY, everything matching is listed.
87    #[arg(long, value_name = "TYPE")]
88    returns: Option<String>,
89
90    /// Maximum number of results.
91    #[arg(short, long, default_value_t = 20)]
92    limit: usize,
93
94    /// Pretty JSON array.
95    #[arg(short, long, conflicts_with = "ndjson")]
96    json: bool,
97
98    /// Newline-delimited JSON (one record per line).
99    #[arg(short = 'J', long)]
100    ndjson: bool,
101
102    /// Omit schema descriptions from text output (they're shown by default;
103    /// `--json`/`--ndjson` always carry the full text).
104    #[arg(short = 'D', long)]
105    no_description: bool,
106
107    /// Force semantic-only search. By default fuzzy and semantic results are
108    /// combined once the schema's vectors are cached.
109    #[arg(long, hide = HIDE_SEMANTIC)]
110    semantic: bool,
111
112    /// Force fuzzy-only search — skip the semantic combine.
113    #[arg(long, conflicts_with = "semantic")]
114    fuzzy: bool,
115
116    /// Embedding model for --semantic: a local dir / `.onnx` path, or a
117    /// HuggingFace `org/name` id. Defaults to all-MiniLM-L6-v2.
118    #[arg(long, hide = HIDE_SEMANTIC)]
119    model: Option<String>,
120
121    /// Force a re-embed for --semantic, overwriting the cache. Schema edits
122    /// already re-embed on their own; use this for changes the cache can't see
123    /// (e.g. a new model).
124    #[arg(long, hide = HIDE_SEMANTIC)]
125    refresh: bool,
126
127    /// Delete all cached embedding vector files, then exit.
128    #[arg(long, hide = HIDE_SEMANTIC)]
129    clear_cache: bool,
130
131    /// Pre-embed the schema's vectors (warm the cache), then exit.
132    #[arg(long, hide = HIDE_SEMANTIC)]
133    warm: bool,
134
135    /// Print a shell completion script (bash, zsh, fish, ...) to stdout, then exit.
136    #[arg(long, value_name = "SHELL")]
137    completions: Option<Shell>,
138
139    /// Draft a ready-to-paste example operation for the top match — arguments
140    /// as variables, one level of leaf fields selected.
141    #[arg(short = 'e', long, conflicts_with = "resolve")]
142    example: bool,
143
144    /// How many levels of fields --example selects. Deeper levels expand the
145    /// object-valued fields that level 1 leaves as markers.
146    #[arg(long, value_name = "N", default_value_t = 1)]
147    depth: usize,
148
149    /// Jump the top match to its graphql-ruby resolver/method in code, via
150    /// `rq` (must be installed).
151    #[arg(short = 'R', long)]
152    resolve: bool,
153
154    /// Directory of the server code for --resolve (defaults to rq's index).
155    #[arg(long)]
156    code: Option<String>,
157
158    /// Header for URL introspection, `Name: Value` (repeatable) — e.g. an
159    /// `Authorization` token for an auth-gated endpoint.
160    #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
161    header: Vec<String>,
162
163    /// Print a phase-by-phase timing breakdown to stderr (or into --json).
164    #[arg(long)]
165    profile: bool,
166
167    /// Verbose stderr diagnostics: cache hits, rq candidates, and why the
168    /// embedding model loaded or fell back.
169    #[arg(short, long, conflicts_with = "quiet")]
170    verbose: bool,
171
172    /// Suppress status chatter on stderr (results and hard errors still print).
173    #[arg(short, long)]
174    quiet: bool,
175}
176
177/// The chosen output format — computed once, honored by every mode. Text
178/// carries whether to print descriptions; the JSON forms always include them.
179#[derive(Clone, Copy)]
180enum Output {
181    Text { descriptions: bool },
182    Json,
183    Ndjson,
184}
185
186/// A ranked result — from either the fuzzy scorer or the semantic ranker, so
187/// both flow through one output path.
188struct Match<'a> {
189    record: &'a SchemaRecord,
190    score: f64,
191}
192
193/// All fuzzy hits above the quality cutoff, best first — the caller truncates
194/// to the display limit, so the length is the true match count. The `bool` is
195/// [`search::named_hit`]: whether some hit names the query's leaf.
196fn fuzzy_matches<'a>(
197    query: &str,
198    records: &'a [SchemaRecord],
199    filters: search::Filters<'_>,
200) -> (Vec<Match<'a>>, bool) {
201    let mut span = crate::profile::span("fuzzy scan");
202    let hits = search::search(query, records, filters);
203    span.note(|| format!("{} of {} records matched", hits.len(), records.len()));
204    let named = search::named_hit(query, &hits);
205    let matches = hits
206        .into_iter()
207        .map(|h| Match {
208            record: h.record,
209            score: h.score as f64,
210        })
211        .collect();
212    (matches, named)
213}
214
215#[cfg(feature = "_semantic")]
216fn semantic_matches<'a>(
217    query: &str,
218    records: &'a [SchemaRecord],
219    filters: search::Filters<'_>,
220    cli: &Cli,
221) -> Vec<Match<'a>> {
222    crate::semantic::search(
223        query,
224        records,
225        filters,
226        cli.limit,
227        cli.model.as_deref(),
228        cli.refresh,
229    )
230    .into_iter()
231    .map(|(score, record)| Match { record, score })
232    .collect()
233}
234
235/// Merge the fuzzy and semantic rankings via Reciprocal Rank Fusion — precise
236/// name matches and meaning matches both surface, and a record strong in both
237/// rises to the top. Fuzzy is weighted a touch higher so an exact-name hit
238/// keeps the lead; scale-free, so the two score systems needn't be normalized.
239#[cfg(feature = "_semantic")]
240fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
241    use std::collections::HashMap;
242    const K: f64 = 60.0;
243    // Key on the record's stable qualified path (unique per entity) rather than
244    // pointer identity, so fusion stays correct even if a ranker ever returned
245    // records not borrowed from the same slice.
246    let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
247    for (rank, m) in fuzzy.iter().enumerate() {
248        scored
249            .entry(m.record.path.as_str())
250            .or_insert((0.0, m.record))
251            .0 += 1.0 / (K + rank as f64 + 1.0);
252    }
253    for (rank, m) in semantic.iter().enumerate() {
254        scored
255            .entry(m.record.path.as_str())
256            .or_insert((0.0, m.record))
257            .0 += 0.7 / (K + rank as f64 + 1.0);
258    }
259    let mut merged: Vec<Match> = scored
260        .into_values()
261        .map(|(score, record)| Match { record, score })
262        .collect();
263    merged.sort_by(|a, b| b.score.total_cmp(&a.score));
264    merged.truncate(limit);
265    merged
266}
267
268/// Spawn a detached `gqls --warm <source>` so the schema's vectors embed in the
269/// background — the next run gets combined fuzzy+semantic results with no wait.
270/// Opt out with `GQLS_NO_AUTOWARM`. Best-effort; failures are ignored.
271#[cfg(feature = "_semantic")]
272fn spawn_background_warm(source: &str, headers: &[String]) {
273    if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
274        return;
275    }
276    // Single-flight: a detached warm for this source may already be running.
277    // A short-lived lockfile keeps a burst of cold queries from spawning a herd
278    // that all embed the same schema and race the cache.
279    if !claim_warm_lock(source) {
280        return;
281    }
282    if let Ok(exe) = std::env::current_exe() {
283        let mut cmd = std::process::Command::new(exe);
284        cmd.arg("--warm").arg(source);
285        for h in headers {
286            cmd.arg("--header").arg(h);
287        }
288        let _ = cmd
289            .stdin(std::process::Stdio::null())
290            .stdout(std::process::Stdio::null())
291            .stderr(std::process::Stdio::null())
292            .spawn();
293    }
294}
295
296/// Best-effort single-flight guard for background warming: returns true (and
297/// stakes a claim) when no recent warm for `source` is in flight, false when one
298/// likely is. The lockfile self-expires by mtime, so a crashed warm can't wedge
299/// warming forever, and a failed warm won't be retried in a tight loop.
300#[cfg(feature = "_semantic")]
301fn claim_warm_lock(source: &str) -> bool {
302    use std::collections::hash_map::DefaultHasher;
303    use std::hash::{Hash, Hasher};
304    use std::time::Duration;
305
306    const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
307    // Lockfiles live in the system temp dir so the OS auto-reaps them.
308    let dir = crate::paths::temp_dir();
309    let mut h = DefaultHasher::new();
310    source.hash(&mut h);
311    let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
312    if let Ok(meta) = std::fs::metadata(&lock) {
313        if let Ok(modified) = meta.modified() {
314            if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
315                return false; // a recent warm is presumably still running
316            }
317        }
318    }
319    let _ = std::fs::create_dir_all(&dir);
320    std::fs::write(&lock, []).is_ok()
321}
322
323/// Parse `-H "Name: Value"` strings into `(name, value)` pairs.
324fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
325    raw.iter()
326        .map(|h| {
327            let (name, value) = h
328                .split_once(':')
329                .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
330            Ok((name.trim().to_string(), value.trim().to_string()))
331        })
332        .collect()
333}
334
335pub fn run() -> Result<()> {
336    let started = std::time::Instant::now();
337    let cli = Cli::parse();
338    crate::logging::init(cli.verbose, cli.quiet);
339    if cli.profile {
340        crate::profile::enable();
341    }
342
343    if let Some(shell) = cli.completions {
344        let mut cmd = Cli::command();
345        let name = cmd.get_name().to_string();
346        generate(shell, &mut cmd, name, &mut std::io::stdout());
347        return Ok(());
348    }
349
350    if cli.clear_cache {
351        let introspect = crate::load::introspect::clear_cache();
352        let records = crate::load::record_cache::clear();
353        #[cfg(feature = "_semantic")]
354        let vectors = crate::semantic::clear_cache();
355        #[cfg(not(feature = "_semantic"))]
356        let vectors = 0;
357        crate::status!("cleared {} cached file(s)", introspect + records + vectors);
358        return Ok(());
359    }
360
361    let output = if cli.json {
362        Output::Json
363    } else if cli.ndjson {
364        Output::Ndjson
365    } else {
366        Output::Text {
367            descriptions: !cli.no_description,
368        }
369    };
370
371    let kind: Option<Kind> = match &cli.kind {
372        Some(s) => Some(s.parse()?),
373        None => None,
374    };
375
376    // The schema source. With `--warm` and no explicit source, the sole
377    // positional is the schema (there's no query to warm), so `gqls --warm
378    // schema.graphql` — and the background spawn — target the right file.
379    // `--returns` needs no QUERY of its own, so a lone positional beside it is
380    // the schema rather than a query — `gqls --returns Company schema.graphql`
381    // reads the way it looks. Same shape as the `--warm` rule.
382    let positional_is_source = cli.source.is_none()
383        && cli.returns.is_some()
384        && cli.query.as_deref().is_some_and(looks_like_source);
385
386    let source = if let Some(s) = cli.source.clone() {
387        s
388    } else if cli.warm || positional_is_source {
389        match cli.query.clone() {
390            Some(s) => s,
391            None => load::discover()?,
392        }
393    } else {
394        load::discover()?
395    };
396    let load_opts = load::LoadOptions {
397        headers: parse_headers(&cli.header)?,
398        refresh: cli.refresh,
399    };
400    let t_load = std::time::Instant::now();
401    let records = {
402        let mut span = crate::profile::span("load");
403        let records = load::load(&source, &load_opts)?;
404        span.note(|| format!("{} records", records.len()));
405        records
406    };
407    crate::detail!(
408        "loaded {} records in {:.1?}",
409        records.len(),
410        t_load.elapsed()
411    );
412
413    // --warm: embed + cache the schema's vectors, then exit (no query needed).
414    // Also the primitive the background auto-warm spawns.
415    if cli.warm {
416        #[cfg(feature = "_semantic")]
417        {
418            let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
419            crate::status!("cached vectors for {n} record(s)");
420            return Ok(());
421        }
422        #[cfg(not(feature = "_semantic"))]
423        {
424            let _ = (&cli.model, cli.refresh);
425            anyhow::bail!("--warm needs a semantic build");
426        }
427    }
428
429    // clap guarantees a query unless --clear-cache/--completions/--warm/--returns.
430    let query = match cli.query.as_deref() {
431        // a positional consumed as the schema above isn't the query
432        Some(q) if !positional_is_source => q,
433        // `--returns Company` on its own lists everything returning Company
434        _ if cli.returns.is_some() => "*",
435        _ => anyhow::bail!("a QUERY is required (see --help)"),
436    };
437
438    // A wildcard query (`User.*`) enumerates rather than searches: the pattern
439    // does its own scoping and every match is exact, so the qualifier rewrites
440    // and the semantic combine below are both bypassed.
441    let pattern = search::glob::is_pattern(query);
442    if pattern {
443        crate::detail!("wildcard query — enumerating matches for {query:?}");
444    }
445
446    // `User name` — a two-word query whose first word exactly names a type —
447    // is the qualified form typed with a space. Rewrite it, but remember the
448    // loose intent: unlike the dot form, an exact hit here keeps the semantic
449    // combine on ("around this", not "exactly this").
450    let spaced = (!pattern)
451        .then(|| search::spaced_qualifier(query, &records))
452        .flatten();
453    let loose = spaced.is_some();
454    let query = spaced.as_deref().unwrap_or(query);
455    if loose {
456        crate::detail!("two-word query names a type — searching as {query:?}");
457    }
458
459    // A `Type.field` query whose qualifier names a schema type — exactly, or
460    // as its unique closest misspelling — becomes a hard filter to that type's
461    // members, in every search mode. A silent correction would be confusing,
462    // so that case is announced at normal verbosity.
463    let parent = (!pattern)
464        .then(|| search::parent_filter(query, &records))
465        .flatten();
466    if let Some(p) = parent {
467        let (_, qualifier) = search::score::parse_qualified(query);
468        if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
469            crate::detail!("qualifier {p:?} names a type — restricting to its members");
470        } else {
471            crate::status!(
472                "no type named {:?} — using closest match {p:?}",
473                qualifier.unwrap_or_default()
474            );
475        }
476    }
477
478    let filters = search::Filters {
479        kind,
480        parent,
481        returns: cli.returns.as_deref(),
482    };
483
484    if cli.resolve {
485        return run_resolve(
486            query,
487            &source,
488            &records,
489            filters,
490            cli.code.as_deref(),
491            cli.limit,
492            output,
493        );
494    }
495
496    if cli.example {
497        return run_example(query, &records, filters, cli.depth, output);
498    }
499
500    // `total` is the fuzzy match count before the display limit, so the footer
501    // can say how much a raised -l would reveal. Semantic-only mode has no
502    // meaningful total (cosine ranks every record), so it never shows one.
503    let t_rank = std::time::Instant::now();
504    let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
505        let (mut fuzzy, _) = fuzzy_matches(query, &records, filters);
506        let total = fuzzy.len();
507        fuzzy.truncate(cli.limit);
508        (fuzzy, total)
509    } else if cli.semantic {
510        #[cfg(feature = "_semantic")]
511        {
512            if pattern {
513                crate::status!("--semantic ranks by meaning and ignores wildcards in {query:?}");
514            }
515            let matches = semantic_matches(query, &records, filters, &cli);
516            let total = matches.len();
517            (matches, total)
518        }
519        #[cfg(not(feature = "_semantic"))]
520        {
521            let _ = (&cli.model, cli.refresh);
522            anyhow::bail!(
523                "this build has no semantic search — install it with \
524                 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
525            );
526        }
527    } else {
528        // Default: combine fuzzy + semantic when the cache is warm; when cold,
529        // return fuzzy now and warm the vectors in the background for next time.
530        // A strong name hit (exact, or the leaf whole at a word boundary —
531        // `name` → `lastName`) skips the combine outright: the user typed a
532        // word that exists, so semantic ranking would only append lookalike
533        // filler below it (and cost the model load).
534        let (mut fuzzy, named) = fuzzy_matches(query, &records, filters);
535        let total = fuzzy.len();
536        fuzzy.truncate(cli.limit);
537        #[cfg(feature = "_semantic")]
538        {
539            // A wildcard enumerates exact matches, and a strong name hit means
540            // fuzzy already found the word typed — neither wants meaning-based
541            // lookalikes appended (nor the model load they cost).
542            let skip = if pattern {
543                Some("wildcard enumeration")
544            } else if named && !loose {
545                Some("strong name match")
546            } else {
547                None
548            };
549            if let Some(why) = skip {
550                crate::detail!("{why} — semantic ranking skipped (--semantic to force)");
551                (fuzzy, total)
552            } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
553                let semantic = semantic_matches(query, &records, filters, &cli);
554                (combine(fuzzy, semantic, cli.limit), total)
555            } else {
556                spawn_background_warm(&source, &cli.header);
557                crate::status!(
558                    "building the semantic index in the background; next run ranks by \
559                     meaning (--semantic to wait, --fuzzy to skip)"
560                );
561                (fuzzy, total)
562            }
563        }
564        #[cfg(not(feature = "_semantic"))]
565        {
566            let _ = (&cli.model, cli.refresh, named, loose);
567            (fuzzy, total)
568        }
569    };
570
571    crate::detail!("ranked in {:.1?}", t_rank.elapsed());
572    let out_span = crate::profile::span("output");
573
574    if matches.is_empty() {
575        crate::status!("no matches for {query:?}");
576    }
577    output.write_matches(&matches)?;
578    drop(out_span);
579    if crate::profile::enabled() {
580        // Always to stderr, so stdout stays exactly the results — and as JSON
581        // when the caller asked for JSON, so a baseline can be stored and
582        // diffed rather than eyeballed.
583        match output {
584            Output::Json | Output::Ndjson => {
585                eprintln!("{}", crate::profile::json(started.elapsed()));
586            }
587            Output::Text { .. } => {
588                for line in crate::profile::report(started.elapsed()) {
589                    eprintln!("{line}");
590                }
591            }
592        }
593    }
594    if total > matches.len() {
595        crate::detail!(
596            "{total} matches; showing top {} (-l to adjust)",
597            matches.len()
598        );
599    }
600    Ok(())
601}
602
603impl Output {
604    fn write_matches(self, matches: &[Match]) -> Result<()> {
605        #[derive(Serialize)]
606        struct Row<'a> {
607            #[serde(flatten)]
608            record: &'a SchemaRecord,
609            score: f64,
610        }
611        let rows = || {
612            matches.iter().map(|m| Row {
613                record: m.record,
614                score: m.score,
615            })
616        };
617        match self {
618            Output::Json => println!(
619                "{}",
620                serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
621            ),
622            Output::Ndjson => {
623                for row in rows() {
624                    println!("{}", serde_json::to_string(&row)?);
625                }
626            }
627            Output::Text { descriptions } => print_text(matches, descriptions),
628        }
629        Ok(())
630    }
631}
632
633/// Longest description rendered inline. A schema doc can run to paragraphs;
634/// one line per result keeps the output greppable, so the rest is elided
635/// (`--json` carries the full text).
636const DESCRIPTION_WIDTH: usize = 72;
637
638fn print_text(matches: &[Match], descriptions: bool) {
639    let width = matches
640        .iter()
641        .map(|m| display_path(m.record).len())
642        .max()
643        .unwrap_or(0)
644        .min(48);
645
646    for m in matches {
647        let r = m.record;
648        let path = display_path(r);
649        let ret = r
650            .type_ref
651            .as_deref()
652            .map(|t| format!(" -> {t}"))
653            .unwrap_or_default();
654        let dep = if r.deprecated.is_some() {
655            " (deprecated)"
656        } else {
657            ""
658        };
659        let desc = if descriptions {
660            r.description
661                .as_deref()
662                .map(summarize)
663                .filter(|d| !d.is_empty())
664                .map(|d| format!(" — {d}"))
665                .unwrap_or_default()
666        } else {
667            String::new()
668        };
669        println!(
670            "{path:<width$}{ret}  [{kind}]{dep}{desc}",
671            kind = r.kind.as_str()
672        );
673    }
674}
675
676/// A schema description as one line: whitespace (including the newlines of a
677/// block description) collapsed, then elided at [`DESCRIPTION_WIDTH`].
678fn summarize(description: &str) -> String {
679    let mut out = String::new();
680    for (i, word) in description.split_whitespace().enumerate() {
681        if i > 0 {
682            out.push(' ');
683        }
684        out.push_str(word);
685        if out.chars().count() > DESCRIPTION_WIDTH {
686            let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
687            return format!("{}…", kept.trim_end());
688        }
689    }
690    out
691}
692
693/// Find the field, then draft an operation that calls it.
694fn run_example(
695    query: &str,
696    records: &[SchemaRecord],
697    filters: search::Filters<'_>,
698    depth: usize,
699    output: Output,
700) -> Result<()> {
701    let Some(top) = search::search(query, records, filters).into_iter().next() else {
702        anyhow::bail!("no schema entity matches {query:?} to draft");
703    };
704    crate::status!("drafting an operation for {}", top.record.path);
705    let example = crate::example::build(top.record, records, depth)?;
706    if !example.deprecated.is_empty() {
707        // Selected anyway and marked inline, but worth saying out loud —
708        // pasting a deprecated field is the kind of thing you want to know now.
709        crate::status!(
710            "deprecated: {} (flagged inline)",
711            example.deprecated.join(", ")
712        );
713    }
714    if let Some(via) = &example.via {
715        if example.alternatives.is_empty() {
716            crate::detail!("reached through {via}");
717        } else {
718            // Several roots qualify; name the pick and the runners-up rather
719            // than passing the choice off as obvious.
720            crate::status!(
721                "reached through {via}; also reachable via {}",
722                example.alternatives.join(", ")
723            );
724        }
725    }
726
727    let payload = serde_json::json!({
728        "path": top.record.path,
729        "operation": example.operation,
730        "variables": example.variables,
731        "optional_args": example.optional,
732        "input_types": example.input_types,
733        "deprecated": example.deprecated,
734    });
735    match output {
736        Output::Json => println!("{}", serde_json::to_string_pretty(&payload)?),
737        Output::Ndjson => println!("{}", serde_json::to_string(&payload)?),
738        Output::Text { .. } => {
739            print!("{}", example.operation);
740            if !example.optional.is_empty() {
741                println!("\n# optional arguments, omitted above:");
742                for arg in &example.optional {
743                    println!("#   {arg}");
744                }
745            }
746            if !example.input_types.is_empty() {
747                println!("\n# input types:");
748                for block in &example.input_types {
749                    for line in block {
750                        println!("#   {line}");
751                    }
752                }
753            }
754            println!("\n# variables");
755            println!("{}", serde_json::to_string_pretty(&example.variables)?);
756        }
757    }
758    Ok(())
759}
760
761/// Fuzzy-find the field, then hand it to rq to locate its resolver in code.
762fn run_resolve(
763    query: &str,
764    source: &str,
765    records: &[SchemaRecord],
766    filters: search::Filters<'_>,
767    code: Option<&str>,
768    limit: usize,
769    output: Output,
770) -> Result<()> {
771    if code.is_none() {
772        crate::status!("searching code in the current directory (--code to search elsewhere)");
773    }
774    let Some(top) = search::search(query, records, filters).into_iter().next() else {
775        anyhow::bail!("no schema entity matches {query:?} to resolve");
776    };
777    crate::status!("resolving {} …", top.record.path);
778    // a local file schema (not a URL) enables package-proximity ranking
779    let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
780        .then(|| std::path::Path::new(source))
781        .filter(|p| p.exists());
782    let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
783
784    match output {
785        Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
786        Output::Ndjson => {
787            for h in &hits {
788                println!("{}", serde_json::to_string(h)?);
789            }
790        }
791        Output::Text { .. } => {
792            if hits.is_empty() {
793                crate::status!(
794                    "no code definition found for {} (-v shows what was tried)",
795                    top.record.path
796                );
797            }
798            // Say so when the best we have is a bare name search rather than a
799            // graphql-ruby convention: an unlabelled list invites trusting a
800            // top-ranked guess, which is worse than offering nothing.
801            if hits.first().is_some_and(|h| h.loose) {
802                crate::status!(
803                    "no graphql-ruby convention matched {} — these are name-similarity \
804                     guesses, not resolver lookups",
805                    top.record.path
806                );
807            }
808            for h in &hits {
809                let flag = if h.loose { "  (guess)" } else { "" };
810                println!("{}:{}  {}  (via {}){flag}", h.file, h.line, h.name, h.via);
811            }
812        }
813    }
814    Ok(())
815}
816
817/// Whether a positional argument is a schema source rather than a query.
818/// Syntactic only (no filesystem check): schema sources are URLs or files with
819/// a schema extension, none of which is a legal GraphQL name, so this can't
820/// swallow a real query.
821fn looks_like_source(arg: &str) -> bool {
822    arg.starts_with("http://")
823        || arg.starts_with("https://")
824        || [".graphql", ".graphqls", ".gql", ".json"]
825            .iter()
826            .any(|ext| arg.to_ascii_lowercase().ends_with(ext))
827}
828
829/// `Query.user(id: ID!, first: Int)` — path plus a compact arg signature.
830fn display_path(r: &SchemaRecord) -> String {
831    if r.args.is_empty() {
832        r.path.clone()
833    } else {
834        format!("{}({})", r.path, r.args.join(", "))
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::{looks_like_source, summarize, DESCRIPTION_WIDTH};
841
842    #[test]
843    fn recognizes_schema_sources_but_not_queries() {
844        assert!(looks_like_source("schema.graphql"));
845        assert!(looks_like_source("a/b/Schema.GraphQLS"));
846        assert!(looks_like_source("dump.json"));
847        assert!(looks_like_source("https://api.example.com/graphql"));
848        // legal GraphQL names must stay queries
849        assert!(!looks_like_source("User.email"));
850        assert!(!looks_like_source("Company"));
851        assert!(!looks_like_source("User.*"));
852    }
853
854    #[test]
855    fn collapses_block_descriptions_to_one_line() {
856        assert_eq!(summarize("  Look up\n  a user.\n"), "Look up a user.");
857        assert_eq!(summarize("   "), "");
858    }
859
860    #[test]
861    fn elides_past_the_width() {
862        let long = "word ".repeat(60);
863        let out = summarize(&long);
864        assert!(out.ends_with('…'), "{out}");
865        assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
866    }
867
868    #[test]
869    fn keeps_a_description_that_fits() {
870        let s = "An account.";
871        assert_eq!(summarize(s), s);
872    }
873}