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