Skip to main content

gqls/
cli.rs

1//! clap CLI, dispatch, and output formatting (text / json / ndjson).
2
3use anyhow::Result;
4use clap::Parser;
5use serde::Serialize;
6
7use crate::load;
8use crate::model::{Kind, SchemaRecord};
9use crate::search;
10
11const EXAMPLES: &str = "\
12EXAMPLES:
13  gqls user schema.graphql            fuzzy search an SDL file
14  gqls createUser -k mutation         restrict to a kind (schema auto-discovered)
15  gqls User.email                     qualified Type.field query
16  gqls repo schema.json               search a local introspection dump
17  gqls repo https://api/graphql       introspect a live endpoint
18  gqls 'cancel a subscription' -s     semantic search (build --features semantic)
19  gqls Query.user -R --code ./app     jump to the graphql-ruby resolver
20  gqls user schema.graphql -j         JSON output (-J for ndjson)
21";
22
23#[derive(Parser)]
24#[command(
25    name = "gqls",
26    version,
27    about = "Fuzzy + semantic search over a GraphQL schema.",
28    long_about = "Search the types, fields, args, and directives in a GraphQL schema from the \
29                  terminal. Input is an SDL file, a local introspection JSON dump, or a live \
30                  http(s) endpoint; with no source, gqls discovers a schema in the current tree. \
31                  Fuzzy by default; --semantic ranks by meaning; --resolve jumps to the \
32                  graphql-ruby resolver via rq. All modes support -j/--json and -J/--ndjson.",
33    after_help = EXAMPLES
34)]
35struct Cli {
36    /// Search query. Fuzzy by default; abbreviations like `usr` match `User`,
37    /// and `Type.field` queries match against the qualified path.
38    #[arg(required_unless_present = "clear_cache")]
39    query: Option<String>,
40
41    /// Schema source: a `.graphql`/`.graphqls` SDL file, a `.json` introspection
42    /// dump, or an http(s) URL (introspected live). If omitted, gqls searches
43    /// the current directory tree for a schema.
44    source: Option<String>,
45
46    /// Restrict to a kind (object, field, query, mutation, enum, scalar, ...).
47    #[arg(short, long)]
48    kind: Option<String>,
49
50    /// Maximum number of results.
51    #[arg(short, long, default_value_t = 20)]
52    limit: usize,
53
54    /// Pretty JSON array.
55    #[arg(short, long, conflicts_with = "ndjson")]
56    json: bool,
57
58    /// Newline-delimited JSON (one record per line).
59    #[arg(short = 'J', long)]
60    ndjson: bool,
61
62    /// Semantic (embedding) search instead of fuzzy. Requires a build with
63    /// `--features semantic`.
64    #[arg(short, long)]
65    semantic: bool,
66
67    /// Embedding model for --semantic: a local dir / `.onnx` path, or a
68    /// HuggingFace `org/name` id. Defaults to all-MiniLM-L6-v2.
69    #[arg(long)]
70    model: Option<String>,
71
72    /// Re-embed for --semantic even if a cached vector file exists (and
73    /// refresh the cache). Schema edits already invalidate the cache; use this
74    /// to force it (e.g. after a model change).
75    #[arg(long)]
76    refresh: bool,
77
78    /// Delete all cached embedding vector files, then exit.
79    #[arg(long)]
80    clear_cache: bool,
81
82    /// Resolve the top match to its graphql-ruby resolver/method in code via
83    /// `rq` (must be installed) — find the field, then jump to its definition.
84    #[arg(short = 'R', long)]
85    resolve: bool,
86
87    /// Directory of the server code for --resolve (defaults to rq's index).
88    #[arg(long)]
89    code: Option<String>,
90}
91
92/// The chosen output format — computed once, honored by every mode.
93#[derive(Clone, Copy)]
94enum Output {
95    Text,
96    Json,
97    Ndjson,
98}
99
100/// A ranked result — from either the fuzzy scorer or the semantic ranker, so
101/// both flow through one output path.
102struct Match<'a> {
103    record: &'a SchemaRecord,
104    score: f64,
105}
106
107pub fn run() -> Result<()> {
108    let cli = Cli::parse();
109
110    if cli.clear_cache {
111        #[cfg(feature = "semantic")]
112        {
113            let n = crate::semantic::clear_cache();
114            eprintln!("gqls: cleared {n} cached vector file(s)");
115            return Ok(());
116        }
117        #[cfg(not(feature = "semantic"))]
118        anyhow::bail!("no embedding cache in this build (built without --features semantic)");
119    }
120
121    // clap guarantees this is present (required_unless_present = clear_cache).
122    let query = cli
123        .query
124        .as_deref()
125        .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
126
127    let output = if cli.json {
128        Output::Json
129    } else if cli.ndjson {
130        Output::Ndjson
131    } else {
132        Output::Text
133    };
134
135    let kind: Option<Kind> = match &cli.kind {
136        Some(s) => Some(s.parse()?),
137        None => None,
138    };
139
140    let source = match cli.source {
141        Some(s) => s,
142        None => load::discover()?,
143    };
144    let records = load::load(&source)?;
145
146    if cli.resolve {
147        return run_resolve(query, &records, kind, cli.code.as_deref(), cli.limit, output);
148    }
149
150    let matches: Vec<Match> = if cli.semantic {
151        #[cfg(feature = "semantic")]
152        {
153            crate::semantic::search(query, &records, kind, cli.limit, cli.model.as_deref(), cli.refresh)
154                .into_iter()
155                .map(|(score, record)| Match { record, score })
156                .collect()
157        }
158        #[cfg(not(feature = "semantic"))]
159        {
160            let _ = (&cli.model, cli.refresh);
161            anyhow::bail!(
162                "this build has no semantic search — rebuild with `cargo build --features semantic`"
163            );
164        }
165    } else {
166        search::search(query, &records, kind, cli.limit)
167            .into_iter()
168            .map(|h| Match {
169                record: h.record,
170                score: h.score as f64,
171            })
172            .collect()
173    };
174
175    if matches.is_empty() {
176        eprintln!("gqls: no matches for {query:?}");
177    }
178    output.write_matches(&matches)
179}
180
181impl Output {
182    fn write_matches(self, matches: &[Match]) -> Result<()> {
183        #[derive(Serialize)]
184        struct Row<'a> {
185            #[serde(flatten)]
186            record: &'a SchemaRecord,
187            score: f64,
188        }
189        let rows = || {
190            matches.iter().map(|m| Row {
191                record: m.record,
192                score: m.score,
193            })
194        };
195        match self {
196            Output::Json => println!("{}", serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?),
197            Output::Ndjson => {
198                for row in rows() {
199                    println!("{}", serde_json::to_string(&row)?);
200                }
201            }
202            Output::Text => print_text(matches),
203        }
204        Ok(())
205    }
206}
207
208fn print_text(matches: &[Match]) {
209    let width = matches
210        .iter()
211        .map(|m| display_path(m.record).len())
212        .max()
213        .unwrap_or(0)
214        .min(48);
215
216    for m in matches {
217        let r = m.record;
218        let path = display_path(r);
219        let ret = r
220            .type_ref
221            .as_deref()
222            .map(|t| format!(" -> {t}"))
223            .unwrap_or_default();
224        let dep = if r.deprecated.is_some() {
225            " (deprecated)"
226        } else {
227            ""
228        };
229        println!("{path:<width$}{ret}  [{kind}]{dep}", kind = r.kind.as_str());
230    }
231}
232
233/// Fuzzy-find the field, then hand it to rq to locate its resolver in code.
234fn run_resolve(
235    query: &str,
236    records: &[SchemaRecord],
237    kind: Option<Kind>,
238    code: Option<&str>,
239    limit: usize,
240    output: Output,
241) -> Result<()> {
242    if code.is_none() {
243        eprintln!(
244            "gqls: no --code given; resolving against rq's index for the current directory"
245        );
246    }
247    let Some(top) = search::search(query, records, kind, 1).into_iter().next() else {
248        anyhow::bail!("no schema entity matches {query:?} to resolve");
249    };
250    eprintln!("gqls: resolving {} …", top.record.path);
251    let hits = crate::resolve::resolve(top.record, code, limit.min(10))?;
252
253    match output {
254        Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
255        Output::Ndjson => {
256            for h in &hits {
257                println!("{}", serde_json::to_string(h)?);
258            }
259        }
260        Output::Text => {
261            if hits.is_empty() {
262                eprintln!(
263                    "gqls: no code definition found for {} (tried graphql-ruby rq candidates)",
264                    top.record.path
265                );
266            }
267            for h in &hits {
268                println!("{}:{}  {}  (via {})", h.file, h.line, h.name, h.via);
269            }
270        }
271    }
272    Ok(())
273}
274
275/// `Query.user(id: ID!, first: Int)` — path plus a compact arg signature.
276fn display_path(r: &SchemaRecord) -> String {
277    if r.args.is_empty() {
278        r.path.clone()
279    } else {
280        format!("{}({})", r.path, r.args.join(", "))
281    }
282}