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