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