1use 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 #[arg(required_unless_present_any = ["clear_cache", "completions"])]
40 query: Option<String>,
41
42 source: Option<String>,
46
47 #[arg(short, long)]
49 kind: Option<String>,
50
51 #[arg(short, long, default_value_t = 20)]
53 limit: usize,
54
55 #[arg(short, long, conflicts_with = "ndjson")]
57 json: bool,
58
59 #[arg(short = 'J', long)]
61 ndjson: bool,
62
63 #[arg(short, long)]
66 semantic: bool,
67
68 #[arg(long)]
71 model: Option<String>,
72
73 #[arg(long)]
77 refresh: bool,
78
79 #[arg(long)]
81 clear_cache: bool,
82
83 #[arg(long, value_name = "SHELL")]
85 completions: Option<Shell>,
86
87 #[arg(short = 'R', long)]
90 resolve: bool,
91
92 #[arg(long)]
94 code: Option<String>,
95}
96
97#[derive(Clone, Copy)]
99enum Output {
100 Text,
101 Json,
102 Ndjson,
103}
104
105struct 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 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
245fn 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
287fn 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}