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