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 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 #[arg(required_unless_present_any = ["clear_cache", "completions"])]
61 query: Option<String>,
62
63 source: Option<String>,
67
68 #[arg(short, long)]
70 kind: Option<String>,
71
72 #[arg(short, long, default_value_t = 20)]
74 limit: usize,
75
76 #[arg(short, long, conflicts_with = "ndjson")]
78 json: bool,
79
80 #[arg(short = 'J', long)]
82 ndjson: bool,
83
84 #[arg(short, long, hide = HIDE_SEMANTIC)]
86 semantic: bool,
87
88 #[arg(long, hide = HIDE_SEMANTIC)]
91 model: Option<String>,
92
93 #[arg(long, hide = HIDE_SEMANTIC)]
97 refresh: bool,
98
99 #[arg(long, hide = HIDE_SEMANTIC)]
101 clear_cache: bool,
102
103 #[arg(long, value_name = "SHELL")]
105 completions: Option<Shell>,
106
107 #[arg(short = 'R', long)]
110 resolve: bool,
111
112 #[arg(long)]
114 code: Option<String>,
115}
116
117#[derive(Clone, Copy)]
119enum Output {
120 Text,
121 Json,
122 Ndjson,
123}
124
125struct 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 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, &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
266fn run_resolve(
268 query: &str,
269 records: &[SchemaRecord],
270 kind: Option<Kind>,
271 code: Option<&str>,
272 limit: usize,
273 output: Output,
274) -> Result<()> {
275 if code.is_none() {
276 eprintln!(
277 "gqls: no --code given; resolving against rq's index for the current directory"
278 );
279 }
280 let Some(top) = search::search(query, records, kind, 1).into_iter().next() else {
281 anyhow::bail!("no schema entity matches {query:?} to resolve");
282 };
283 eprintln!("gqls: resolving {} …", top.record.path);
284 let hits = crate::resolve::resolve(top.record, code, limit.min(10))?;
285
286 match output {
287 Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
288 Output::Ndjson => {
289 for h in &hits {
290 println!("{}", serde_json::to_string(h)?);
291 }
292 }
293 Output::Text => {
294 if hits.is_empty() {
295 eprintln!(
296 "gqls: no code definition found for {} (tried graphql-ruby rq candidates)",
297 top.record.path
298 );
299 }
300 for h in &hits {
301 println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
302 }
303 }
304 }
305 Ok(())
306}
307
308fn display_path(r: &SchemaRecord) -> String {
310 if r.args.is_empty() {
311 r.path.clone()
312 } else {
313 format!("{}({})", r.path, r.args.join(", "))
314 }
315}