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