1use 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 #[arg(required_unless_present = "clear_cache")]
39 query: Option<String>,
40
41 source: Option<String>,
45
46 #[arg(short, long)]
48 kind: Option<String>,
49
50 #[arg(short, long, default_value_t = 20)]
52 limit: usize,
53
54 #[arg(short, long, conflicts_with = "ndjson")]
56 json: bool,
57
58 #[arg(short = 'J', long)]
60 ndjson: bool,
61
62 #[arg(short, long)]
65 semantic: bool,
66
67 #[arg(long)]
70 model: Option<String>,
71
72 #[arg(long)]
76 refresh: bool,
77
78 #[arg(long)]
80 clear_cache: bool,
81
82 #[arg(short = 'R', long)]
85 resolve: bool,
86
87 #[arg(long)]
89 code: Option<String>,
90}
91
92#[derive(Clone, Copy)]
94enum Output {
95 Text,
96 Json,
97 Ndjson,
98}
99
100struct 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 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
233fn 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
275fn 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}