use anyhow::Result;
use clap::{CommandFactory, Parser};
use clap_complete::{generate, Shell};
use serde::Serialize;
use crate::load;
use crate::model::{Kind, SchemaRecord};
use crate::search;
const HIDE_SEMANTIC: bool = !cfg!(feature = "_semantic");
#[cfg(feature = "_semantic")]
const EXAMPLES: &str = "\
EXAMPLES:
gqls user schema.graphql fuzzy search an SDL file
gqls createUser -k mutation restrict to a kind (schema auto-discovered)
gqls User.email qualified Type.field query
gqls repo schema.json search a local introspection dump
gqls repo https://api/graphql introspect a live endpoint
gqls 'cancel a subscription' -s semantic search (rank by meaning)
gqls Query.user -R --code ./app jump to the graphql-ruby resolver
gqls user schema.graphql -j JSON output (-J for ndjson)
";
#[cfg(not(feature = "_semantic"))]
const EXAMPLES: &str = "\
EXAMPLES:
gqls user schema.graphql fuzzy search an SDL file
gqls createUser -k mutation restrict to a kind (schema auto-discovered)
gqls User.email qualified Type.field query
gqls repo schema.json search a local introspection dump
gqls repo https://api/graphql introspect a live endpoint
gqls Query.user -R --code ./app jump to the graphql-ruby resolver
gqls user schema.graphql -j JSON output (-J for ndjson)
Semantic search (-s, rank by meaning) is not compiled into this build. Enable it:
cargo install gqls-cli --features semantic
brew install dpep/tools/gqls
";
#[derive(Parser)]
#[command(
name = "gqls",
version,
about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
terminal. The source is an SDL file, a local introspection JSON dump, or a live \
http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
Fuzzy by default; --semantic ranks by meaning; --resolve jumps to the \
graphql-ruby resolver via rq. All modes support -j/--json and -J/--ndjson.",
after_help = EXAMPLES
)]
struct Cli {
#[arg(required_unless_present_any = ["clear_cache", "completions"])]
query: Option<String>,
source: Option<String>,
#[arg(short, long)]
kind: Option<String>,
#[arg(short, long, default_value_t = 20)]
limit: usize,
#[arg(short, long, conflicts_with = "ndjson")]
json: bool,
#[arg(short = 'J', long)]
ndjson: bool,
#[arg(short, long, hide = HIDE_SEMANTIC)]
semantic: bool,
#[arg(long, hide = HIDE_SEMANTIC)]
model: Option<String>,
#[arg(long, hide = HIDE_SEMANTIC)]
refresh: bool,
#[arg(long, hide = HIDE_SEMANTIC)]
clear_cache: bool,
#[arg(long, value_name = "SHELL")]
completions: Option<Shell>,
#[arg(short = 'R', long)]
resolve: bool,
#[arg(long)]
code: Option<String>,
}
#[derive(Clone, Copy)]
enum Output {
Text,
Json,
Ndjson,
}
struct Match<'a> {
record: &'a SchemaRecord,
score: f64,
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut std::io::stdout());
return Ok(());
}
if cli.clear_cache {
#[cfg(feature = "_semantic")]
{
let n = crate::semantic::clear_cache();
eprintln!("gqls: cleared {n} cached vector file(s)");
return Ok(());
}
#[cfg(not(feature = "_semantic"))]
anyhow::bail!("no embedding cache in this build (built without --features semantic)");
}
let query = cli
.query
.as_deref()
.ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
let output = if cli.json {
Output::Json
} else if cli.ndjson {
Output::Ndjson
} else {
Output::Text
};
let kind: Option<Kind> = match &cli.kind {
Some(s) => Some(s.parse()?),
None => None,
};
let source = match cli.source {
Some(s) => s,
None => load::discover()?,
};
let records = load::load(&source)?;
if cli.resolve {
return run_resolve(query, &records, kind, cli.code.as_deref(), cli.limit, output);
}
let matches: Vec<Match> = if cli.semantic {
#[cfg(feature = "_semantic")]
{
crate::semantic::search(query, &records, kind, cli.limit, cli.model.as_deref(), cli.refresh)
.into_iter()
.map(|(score, record)| Match { record, score })
.collect()
}
#[cfg(not(feature = "_semantic"))]
{
let _ = (&cli.model, cli.refresh);
anyhow::bail!(
"this build has no semantic search — install it with \
`cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
);
}
} else {
search::search(query, &records, kind, cli.limit)
.into_iter()
.map(|h| Match {
record: h.record,
score: h.score as f64,
})
.collect()
};
if matches.is_empty() {
eprintln!("gqls: no matches for {query:?}");
}
output.write_matches(&matches)
}
impl Output {
fn write_matches(self, matches: &[Match]) -> Result<()> {
#[derive(Serialize)]
struct Row<'a> {
#[serde(flatten)]
record: &'a SchemaRecord,
score: f64,
}
let rows = || {
matches.iter().map(|m| Row {
record: m.record,
score: m.score,
})
};
match self {
Output::Json => println!("{}", serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?),
Output::Ndjson => {
for row in rows() {
println!("{}", serde_json::to_string(&row)?);
}
}
Output::Text => print_text(matches),
}
Ok(())
}
}
fn print_text(matches: &[Match]) {
let width = matches
.iter()
.map(|m| display_path(m.record).len())
.max()
.unwrap_or(0)
.min(48);
for m in matches {
let r = m.record;
let path = display_path(r);
let ret = r
.type_ref
.as_deref()
.map(|t| format!(" -> {t}"))
.unwrap_or_default();
let dep = if r.deprecated.is_some() {
" (deprecated)"
} else {
""
};
println!("{path:<width$}{ret} [{kind}]{dep}", kind = r.kind.as_str());
}
}
fn run_resolve(
query: &str,
records: &[SchemaRecord],
kind: Option<Kind>,
code: Option<&str>,
limit: usize,
output: Output,
) -> Result<()> {
if code.is_none() {
eprintln!(
"gqls: no --code given; resolving against rq's index for the current directory"
);
}
let Some(top) = search::search(query, records, kind, 1).into_iter().next() else {
anyhow::bail!("no schema entity matches {query:?} to resolve");
};
eprintln!("gqls: resolving {} …", top.record.path);
let hits = crate::resolve::resolve(top.record, code, limit.min(10))?;
match output {
Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
Output::Ndjson => {
for h in &hits {
println!("{}", serde_json::to_string(h)?);
}
}
Output::Text => {
if hits.is_empty() {
eprintln!(
"gqls: no code definition found for {} (tried graphql-ruby rq candidates)",
top.record.path
);
}
for h in &hits {
println!("{}:{} {} (via {})", h.file, h.line, h.name, h.via);
}
}
}
Ok(())
}
fn display_path(r: &SchemaRecord) -> String {
if r.args.is_empty() {
r.path.clone()
} else {
format!("{}({})", r.path, r.args.join(", "))
}
}