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