gqls-cli 0.10.0

Fuzzy and semantic search over a GraphQL schema (SDL, introspection JSON, or a live endpoint), plus a field-to-resolver jump.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
//! clap CLI, dispatch, and output formatting (text / json / ndjson).

use anyhow::Result;
use clap::{CommandFactory, Parser};
use clap_complete::{generate, Shell};
use serde::Serialize;

use crate::load;
use crate::model::{Kind, SchemaRecord};
use crate::search;

/// The semantic-only flags (--semantic, --model, --refresh, --clear-cache) are
/// hidden from --help on builds without the feature, where they'd only error.
const HIDE_SEMANTIC: bool = !cfg!(feature = "_semantic");

#[cfg(feature = "_semantic")]
const EXAMPLES: &str = "\
EXAMPLES:
  gqls user schema.graphql            fuzzy search an SDL file
  gqls createUser -k mutation         restrict to a kind (schema auto-discovered)
  gqls User.email                     qualified Type.field query
  gqls User.                          list a type's fields (or 'User.*')
  gqls 'User.{first,last}Name'        also ? for one char, {a,b} to alternate
  gqls --returns Company -k query     find fields by return type, not name
  gqls repo schema.json               search a local introspection dump
  gqls repo https://api/graphql       introspect a live endpoint
  gqls 'cancel a subscription'        rank by meaning (fuzzy + semantic, auto)
  gqls Query.user -R --code ./app     jump to the graphql-ruby resolver
  gqls user schema.graphql -j         JSON output (-J for ndjson)
";

#[cfg(not(feature = "_semantic"))]
const EXAMPLES: &str = "\
EXAMPLES:
  gqls user schema.graphql            fuzzy search an SDL file
  gqls createUser -k mutation         restrict to a kind (schema auto-discovered)
  gqls User.email                     qualified Type.field query
  gqls User.                          list a type's fields (or 'User.*')
  gqls 'User.{first,last}Name'        also ? for one char, {a,b} to alternate
  gqls --returns Company -k query     find fields by return type, not name
  gqls repo schema.json               search a local introspection dump
  gqls repo https://api/graphql       introspect a live endpoint
  gqls Query.user -R --code ./app     jump to the graphql-ruby resolver
  gqls user schema.graphql -j         JSON output (-J for ndjson)

Semantic search (--semantic, rank by meaning) is not compiled into this build. Enable it:
  cargo install gqls-cli --features semantic
  brew install dpep/tools/gqls
";

#[derive(Parser)]
#[command(
    name = "gqls",
    version,
    about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
    long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
                  terminal. The source is an SDL file, a local introspection JSON dump, or a live \
                  http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
                  Fuzzy and semantic results are ranked together by default (--semantic or \
                  --fuzzy forces one); --resolve jumps to the graphql-ruby resolver via rq. All modes \
                  support -j/--json and -J/--ndjson.",
    after_help = EXAMPLES
)]
struct Cli {
    /// Search query. Fuzzy by default; abbreviations like `usr` match `User`,
    /// and `Type.field` queries match against the qualified path. A trailing
    /// dot lists a type's fields (`User.`); the general wildcards (`*` any
    /// run, `?` one char, `{a,b}` alternatives) enumerate too, but quote them
    /// so the shell doesn't expand them first.
    #[arg(required_unless_present_any = ["clear_cache", "completions", "warm", "returns"])]
    query: Option<String>,

    /// Schema source: a `.graphql`/`.graphqls` SDL file, a `.json` introspection
    /// dump, or an http(s) URL (introspected live). If omitted, gqls searches
    /// the current directory tree for a schema.
    source: Option<String>,

    /// Restrict to a kind (object, field, query, mutation, enum, scalar, ...).
    #[arg(short, long)]
    kind: Option<String>,

    /// Restrict to fields returning this type, ignoring `[]`/`!` wrappers —
    /// `--returns Company` finds `myEmployer: Company`. Wildcards work
    /// (`--returns '*Payload'`). With no QUERY, everything matching is listed.
    #[arg(long, value_name = "TYPE")]
    returns: Option<String>,

    /// Maximum number of results.
    #[arg(short, long, default_value_t = 20)]
    limit: usize,

    /// Pretty JSON array.
    #[arg(short, long, conflicts_with = "ndjson")]
    json: bool,

    /// Newline-delimited JSON (one record per line).
    #[arg(short = 'J', long)]
    ndjson: bool,

    /// Omit schema descriptions from text output (they're shown by default;
    /// `--json`/`--ndjson` always carry the full text).
    #[arg(short = 'D', long)]
    no_description: bool,

    /// Force semantic-only search. By default fuzzy and semantic results are
    /// combined once the schema's vectors are cached.
    #[arg(long, hide = HIDE_SEMANTIC)]
    semantic: bool,

    /// Force fuzzy-only search — skip the semantic combine.
    #[arg(long, conflicts_with = "semantic")]
    fuzzy: bool,

    /// Embedding model for --semantic: a local dir / `.onnx` path, or a
    /// HuggingFace `org/name` id. Defaults to all-MiniLM-L6-v2.
    #[arg(long, hide = HIDE_SEMANTIC)]
    model: Option<String>,

    /// Force a re-embed for --semantic, overwriting the cache. Schema edits
    /// already re-embed on their own; use this for changes the cache can't see
    /// (e.g. a new model).
    #[arg(long, hide = HIDE_SEMANTIC)]
    refresh: bool,

    /// Delete all cached embedding vector files, then exit.
    #[arg(long, hide = HIDE_SEMANTIC)]
    clear_cache: bool,

    /// Pre-embed the schema's vectors (warm the cache), then exit.
    #[arg(long, hide = HIDE_SEMANTIC)]
    warm: bool,

    /// Print a shell completion script (bash, zsh, fish, ...) to stdout, then exit.
    #[arg(long, value_name = "SHELL")]
    completions: Option<Shell>,

    /// Jump the top match to its graphql-ruby resolver/method in code, via
    /// `rq` (must be installed).
    #[arg(short = 'R', long)]
    resolve: bool,

    /// Directory of the server code for --resolve (defaults to rq's index).
    #[arg(long)]
    code: Option<String>,

    /// Header for URL introspection, `Name: Value` (repeatable) — e.g. an
    /// `Authorization` token for an auth-gated endpoint.
    #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
    header: Vec<String>,

    /// Verbose stderr diagnostics: cache hits, rq candidates, and why the
    /// embedding model loaded or fell back.
    #[arg(short, long, conflicts_with = "quiet")]
    verbose: bool,

    /// Suppress status chatter on stderr (results and hard errors still print).
    #[arg(short, long)]
    quiet: bool,
}

/// The chosen output format — computed once, honored by every mode. Text
/// carries whether to print descriptions; the JSON forms always include them.
#[derive(Clone, Copy)]
enum Output {
    Text { descriptions: bool },
    Json,
    Ndjson,
}

/// A ranked result — from either the fuzzy scorer or the semantic ranker, so
/// both flow through one output path.
struct Match<'a> {
    record: &'a SchemaRecord,
    score: f64,
}

/// All fuzzy hits above the quality cutoff, best first — the caller truncates
/// to the display limit, so the length is the true match count. The `bool` is
/// [`search::named_hit`]: whether some hit names the query's leaf.
fn fuzzy_matches<'a>(
    query: &str,
    records: &'a [SchemaRecord],
    filters: search::Filters<'_>,
) -> (Vec<Match<'a>>, bool) {
    let hits = search::search(query, records, filters);
    let named = search::named_hit(query, &hits);
    let matches = hits
        .into_iter()
        .map(|h| Match {
            record: h.record,
            score: h.score as f64,
        })
        .collect();
    (matches, named)
}

#[cfg(feature = "_semantic")]
fn semantic_matches<'a>(
    query: &str,
    records: &'a [SchemaRecord],
    filters: search::Filters<'_>,
    cli: &Cli,
) -> Vec<Match<'a>> {
    crate::semantic::search(
        query,
        records,
        filters,
        cli.limit,
        cli.model.as_deref(),
        cli.refresh,
    )
    .into_iter()
    .map(|(score, record)| Match { record, score })
    .collect()
}

/// Merge the fuzzy and semantic rankings via Reciprocal Rank Fusion — precise
/// name matches and meaning matches both surface, and a record strong in both
/// rises to the top. Fuzzy is weighted a touch higher so an exact-name hit
/// keeps the lead; scale-free, so the two score systems needn't be normalized.
#[cfg(feature = "_semantic")]
fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
    use std::collections::HashMap;
    const K: f64 = 60.0;
    // Key on the record's stable qualified path (unique per entity) rather than
    // pointer identity, so fusion stays correct even if a ranker ever returned
    // records not borrowed from the same slice.
    let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
    for (rank, m) in fuzzy.iter().enumerate() {
        scored
            .entry(m.record.path.as_str())
            .or_insert((0.0, m.record))
            .0 += 1.0 / (K + rank as f64 + 1.0);
    }
    for (rank, m) in semantic.iter().enumerate() {
        scored
            .entry(m.record.path.as_str())
            .or_insert((0.0, m.record))
            .0 += 0.7 / (K + rank as f64 + 1.0);
    }
    let mut merged: Vec<Match> = scored
        .into_values()
        .map(|(score, record)| Match { record, score })
        .collect();
    merged.sort_by(|a, b| b.score.total_cmp(&a.score));
    merged.truncate(limit);
    merged
}

/// Spawn a detached `gqls --warm <source>` so the schema's vectors embed in the
/// background — the next run gets combined fuzzy+semantic results with no wait.
/// Opt out with `GQLS_NO_AUTOWARM`. Best-effort; failures are ignored.
#[cfg(feature = "_semantic")]
fn spawn_background_warm(source: &str, headers: &[String]) {
    if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
        return;
    }
    // Single-flight: a detached warm for this source may already be running.
    // A short-lived lockfile keeps a burst of cold queries from spawning a herd
    // that all embed the same schema and race the cache.
    if !claim_warm_lock(source) {
        return;
    }
    if let Ok(exe) = std::env::current_exe() {
        let mut cmd = std::process::Command::new(exe);
        cmd.arg("--warm").arg(source);
        for h in headers {
            cmd.arg("--header").arg(h);
        }
        let _ = cmd
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn();
    }
}

/// Best-effort single-flight guard for background warming: returns true (and
/// stakes a claim) when no recent warm for `source` is in flight, false when one
/// likely is. The lockfile self-expires by mtime, so a crashed warm can't wedge
/// warming forever, and a failed warm won't be retried in a tight loop.
#[cfg(feature = "_semantic")]
fn claim_warm_lock(source: &str) -> bool {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    use std::time::Duration;

    const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
    // Lockfiles live in the system temp dir so the OS auto-reaps them.
    let dir = crate::paths::temp_dir();
    let mut h = DefaultHasher::new();
    source.hash(&mut h);
    let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
    if let Ok(meta) = std::fs::metadata(&lock) {
        if let Ok(modified) = meta.modified() {
            if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
                return false; // a recent warm is presumably still running
            }
        }
    }
    let _ = std::fs::create_dir_all(&dir);
    std::fs::write(&lock, []).is_ok()
}

/// Parse `-H "Name: Value"` strings into `(name, value)` pairs.
fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
    raw.iter()
        .map(|h| {
            let (name, value) = h
                .split_once(':')
                .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
            Ok((name.trim().to_string(), value.trim().to_string()))
        })
        .collect()
}

pub fn run() -> Result<()> {
    let cli = Cli::parse();
    crate::logging::init(cli.verbose, cli.quiet);

    if let Some(shell) = cli.completions {
        let mut cmd = Cli::command();
        let name = cmd.get_name().to_string();
        generate(shell, &mut cmd, name, &mut std::io::stdout());
        return Ok(());
    }

    if cli.clear_cache {
        let introspect = crate::load::introspect::clear_cache();
        let records = crate::load::record_cache::clear();
        #[cfg(feature = "_semantic")]
        let vectors = crate::semantic::clear_cache();
        #[cfg(not(feature = "_semantic"))]
        let vectors = 0;
        crate::status!("cleared {} cached file(s)", introspect + records + vectors);
        return Ok(());
    }

    let output = if cli.json {
        Output::Json
    } else if cli.ndjson {
        Output::Ndjson
    } else {
        Output::Text {
            descriptions: !cli.no_description,
        }
    };

    let kind: Option<Kind> = match &cli.kind {
        Some(s) => Some(s.parse()?),
        None => None,
    };

    // The schema source. With `--warm` and no explicit source, the sole
    // positional is the schema (there's no query to warm), so `gqls --warm
    // schema.graphql` — and the background spawn — target the right file.
    // `--returns` needs no QUERY of its own, so a lone positional beside it is
    // the schema rather than a query — `gqls --returns Company schema.graphql`
    // reads the way it looks. Same shape as the `--warm` rule.
    let positional_is_source = cli.source.is_none()
        && cli.returns.is_some()
        && cli.query.as_deref().is_some_and(looks_like_source);

    let source = if let Some(s) = cli.source.clone() {
        s
    } else if cli.warm || positional_is_source {
        match cli.query.clone() {
            Some(s) => s,
            None => load::discover()?,
        }
    } else {
        load::discover()?
    };
    let load_opts = load::LoadOptions {
        headers: parse_headers(&cli.header)?,
        refresh: cli.refresh,
    };
    let t_load = std::time::Instant::now();
    let records = load::load(&source, &load_opts)?;
    crate::detail!(
        "loaded {} records in {:.1?}",
        records.len(),
        t_load.elapsed()
    );

    // --warm: embed + cache the schema's vectors, then exit (no query needed).
    // Also the primitive the background auto-warm spawns.
    if cli.warm {
        #[cfg(feature = "_semantic")]
        {
            let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
            crate::status!("cached vectors for {n} record(s)");
            return Ok(());
        }
        #[cfg(not(feature = "_semantic"))]
        {
            let _ = (&cli.model, cli.refresh);
            anyhow::bail!("--warm needs a semantic build");
        }
    }

    // clap guarantees a query unless --clear-cache/--completions/--warm/--returns.
    let query = match cli.query.as_deref() {
        // a positional consumed as the schema above isn't the query
        Some(q) if !positional_is_source => q,
        // `--returns Company` on its own lists everything returning Company
        _ if cli.returns.is_some() => "*",
        _ => anyhow::bail!("a QUERY is required (see --help)"),
    };

    // A wildcard query (`User.*`) enumerates rather than searches: the pattern
    // does its own scoping and every match is exact, so the qualifier rewrites
    // and the semantic combine below are both bypassed.
    let pattern = search::glob::is_pattern(query);
    if pattern {
        crate::detail!("wildcard query — enumerating matches for {query:?}");
    }

    // `User name` — a two-word query whose first word exactly names a type —
    // is the qualified form typed with a space. Rewrite it, but remember the
    // loose intent: unlike the dot form, an exact hit here keeps the semantic
    // combine on ("around this", not "exactly this").
    let spaced = (!pattern)
        .then(|| search::spaced_qualifier(query, &records))
        .flatten();
    let loose = spaced.is_some();
    let query = spaced.as_deref().unwrap_or(query);
    if loose {
        crate::detail!("two-word query names a type — searching as {query:?}");
    }

    // A `Type.field` query whose qualifier names a schema type — exactly, or
    // as its unique closest misspelling — becomes a hard filter to that type's
    // members, in every search mode. A silent correction would be confusing,
    // so that case is announced at normal verbosity.
    let parent = (!pattern)
        .then(|| search::parent_filter(query, &records))
        .flatten();
    if let Some(p) = parent {
        let (_, qualifier) = search::score::parse_qualified(query);
        if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
            crate::detail!("qualifier {p:?} names a type — restricting to its members");
        } else {
            crate::status!(
                "no type named {:?} — using closest match {p:?}",
                qualifier.unwrap_or_default()
            );
        }
    }

    let filters = search::Filters {
        kind,
        parent,
        returns: cli.returns.as_deref(),
    };

    if cli.resolve {
        return run_resolve(
            query,
            &source,
            &records,
            filters,
            cli.code.as_deref(),
            cli.limit,
            output,
        );
    }

    // `total` is the fuzzy match count before the display limit, so the footer
    // can say how much a raised -l would reveal. Semantic-only mode has no
    // meaningful total (cosine ranks every record), so it never shows one.
    let t_rank = std::time::Instant::now();
    let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
        let (mut fuzzy, _) = fuzzy_matches(query, &records, filters);
        let total = fuzzy.len();
        fuzzy.truncate(cli.limit);
        (fuzzy, total)
    } else if cli.semantic {
        #[cfg(feature = "_semantic")]
        {
            if pattern {
                crate::status!("--semantic ranks by meaning and ignores wildcards in {query:?}");
            }
            let matches = semantic_matches(query, &records, filters, &cli);
            let total = matches.len();
            (matches, total)
        }
        #[cfg(not(feature = "_semantic"))]
        {
            let _ = (&cli.model, cli.refresh);
            anyhow::bail!(
                "this build has no semantic search — install it with \
                 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
            );
        }
    } else {
        // Default: combine fuzzy + semantic when the cache is warm; when cold,
        // return fuzzy now and warm the vectors in the background for next time.
        // A strong name hit (exact, or the leaf whole at a word boundary —
        // `name` → `lastName`) skips the combine outright: the user typed a
        // word that exists, so semantic ranking would only append lookalike
        // filler below it (and cost the model load).
        let (mut fuzzy, named) = fuzzy_matches(query, &records, filters);
        let total = fuzzy.len();
        fuzzy.truncate(cli.limit);
        #[cfg(feature = "_semantic")]
        {
            // A wildcard enumerates exact matches, and a strong name hit means
            // fuzzy already found the word typed — neither wants meaning-based
            // lookalikes appended (nor the model load they cost).
            let skip = if pattern {
                Some("wildcard enumeration")
            } else if named && !loose {
                Some("strong name match")
            } else {
                None
            };
            if let Some(why) = skip {
                crate::detail!("{why} — semantic ranking skipped (--semantic to force)");
                (fuzzy, total)
            } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
                let semantic = semantic_matches(query, &records, filters, &cli);
                (combine(fuzzy, semantic, cli.limit), total)
            } else {
                spawn_background_warm(&source, &cli.header);
                crate::status!(
                    "building the semantic index in the background; next run ranks by \
                     meaning (--semantic to wait, --fuzzy to skip)"
                );
                (fuzzy, total)
            }
        }
        #[cfg(not(feature = "_semantic"))]
        {
            let _ = (&cli.model, cli.refresh, named, loose);
            (fuzzy, total)
        }
    };

    crate::detail!("ranked in {:.1?}", t_rank.elapsed());

    if matches.is_empty() {
        crate::status!("no matches for {query:?}");
    }
    output.write_matches(&matches)?;
    if total > matches.len() {
        crate::detail!(
            "{total} matches; showing top {} (-l to adjust)",
            matches.len()
        );
    }
    Ok(())
}

impl Output {
    fn write_matches(self, matches: &[Match]) -> Result<()> {
        #[derive(Serialize)]
        struct Row<'a> {
            #[serde(flatten)]
            record: &'a SchemaRecord,
            score: f64,
        }
        let rows = || {
            matches.iter().map(|m| Row {
                record: m.record,
                score: m.score,
            })
        };
        match self {
            Output::Json => println!(
                "{}",
                serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
            ),
            Output::Ndjson => {
                for row in rows() {
                    println!("{}", serde_json::to_string(&row)?);
                }
            }
            Output::Text { descriptions } => print_text(matches, descriptions),
        }
        Ok(())
    }
}

/// Longest description rendered inline. A schema doc can run to paragraphs;
/// one line per result keeps the output greppable, so the rest is elided
/// (`--json` carries the full text).
const DESCRIPTION_WIDTH: usize = 72;

fn print_text(matches: &[Match], descriptions: bool) {
    let width = matches
        .iter()
        .map(|m| display_path(m.record).len())
        .max()
        .unwrap_or(0)
        .min(48);

    for m in matches {
        let r = m.record;
        let path = display_path(r);
        let ret = r
            .type_ref
            .as_deref()
            .map(|t| format!(" -> {t}"))
            .unwrap_or_default();
        let dep = if r.deprecated.is_some() {
            " (deprecated)"
        } else {
            ""
        };
        let desc = if descriptions {
            r.description
                .as_deref()
                .map(summarize)
                .filter(|d| !d.is_empty())
                .map(|d| format!("{d}"))
                .unwrap_or_default()
        } else {
            String::new()
        };
        println!(
            "{path:<width$}{ret}  [{kind}]{dep}{desc}",
            kind = r.kind.as_str()
        );
    }
}

/// A schema description as one line: whitespace (including the newlines of a
/// block description) collapsed, then elided at [`DESCRIPTION_WIDTH`].
fn summarize(description: &str) -> String {
    let mut out = String::new();
    for (i, word) in description.split_whitespace().enumerate() {
        if i > 0 {
            out.push(' ');
        }
        out.push_str(word);
        if out.chars().count() > DESCRIPTION_WIDTH {
            let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
            return format!("{}", kept.trim_end());
        }
    }
    out
}

/// Fuzzy-find the field, then hand it to rq to locate its resolver in code.
fn run_resolve(
    query: &str,
    source: &str,
    records: &[SchemaRecord],
    filters: search::Filters<'_>,
    code: Option<&str>,
    limit: usize,
    output: Output,
) -> Result<()> {
    if code.is_none() {
        crate::status!("searching code in the current directory (--code to search elsewhere)");
    }
    let Some(top) = search::search(query, records, filters).into_iter().next() else {
        anyhow::bail!("no schema entity matches {query:?} to resolve");
    };
    crate::status!("resolving {} …", top.record.path);
    // a local file schema (not a URL) enables package-proximity ranking
    let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
        .then(|| std::path::Path::new(source))
        .filter(|p| p.exists());
    let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;

    match output {
        Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
        Output::Ndjson => {
            for h in &hits {
                println!("{}", serde_json::to_string(h)?);
            }
        }
        Output::Text { .. } => {
            if hits.is_empty() {
                crate::status!(
                    "no code definition found for {} (-v shows what was tried)",
                    top.record.path
                );
            }
            for h in &hits {
                println!("{}:{}  {}  (via {})", h.file, h.line, h.name, h.via);
            }
        }
    }
    Ok(())
}

/// Whether a positional argument is a schema source rather than a query.
/// Syntactic only (no filesystem check): schema sources are URLs or files with
/// a schema extension, none of which is a legal GraphQL name, so this can't
/// swallow a real query.
fn looks_like_source(arg: &str) -> bool {
    arg.starts_with("http://")
        || arg.starts_with("https://")
        || [".graphql", ".graphqls", ".gql", ".json"]
            .iter()
            .any(|ext| arg.to_ascii_lowercase().ends_with(ext))
}

/// `Query.user(id: ID!, first: Int)` — path plus a compact arg signature.
fn display_path(r: &SchemaRecord) -> String {
    if r.args.is_empty() {
        r.path.clone()
    } else {
        format!("{}({})", r.path, r.args.join(", "))
    }
}

#[cfg(test)]
mod tests {
    use super::{looks_like_source, summarize, DESCRIPTION_WIDTH};

    #[test]
    fn recognizes_schema_sources_but_not_queries() {
        assert!(looks_like_source("schema.graphql"));
        assert!(looks_like_source("a/b/Schema.GraphQLS"));
        assert!(looks_like_source("dump.json"));
        assert!(looks_like_source("https://api.example.com/graphql"));
        // legal GraphQL names must stay queries
        assert!(!looks_like_source("User.email"));
        assert!(!looks_like_source("Company"));
        assert!(!looks_like_source("User.*"));
    }

    #[test]
    fn collapses_block_descriptions_to_one_line() {
        assert_eq!(summarize("  Look up\n  a user.\n"), "Look up a user.");
        assert_eq!(summarize("   "), "");
    }

    #[test]
    fn elides_past_the_width() {
        let long = "word ".repeat(60);
        let out = summarize(&long);
        assert!(out.ends_with(''), "{out}");
        assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
    }

    #[test]
    fn keeps_a_description_that_fits() {
        let s = "An account.";
        assert_eq!(summarize(s), s);
    }
}