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' rank by meaning (fuzzy + semantic, auto)
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 and semantic results are ranked together by default (--semantic or \
--fuzzy forces one); --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", "warm"])]
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(long, hide = HIDE_SEMANTIC)]
semantic: bool,
#[arg(long, conflicts_with = "semantic")]
fuzzy: 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, hide = HIDE_SEMANTIC)]
warm: 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,
}
fn fuzzy_matches<'a>(
query: &str,
records: &'a [SchemaRecord],
kind: Option<Kind>,
limit: usize,
) -> Vec<Match<'a>> {
search::search(query, records, kind, limit)
.into_iter()
.map(|h| Match {
record: h.record,
score: h.score as f64,
})
.collect()
}
#[cfg(feature = "_semantic")]
fn semantic_matches<'a>(
query: &str,
records: &'a [SchemaRecord],
kind: Option<Kind>,
cli: &Cli,
) -> Vec<Match<'a>> {
crate::semantic::search(query, records, kind, cli.limit, cli.model.as_deref(), cli.refresh)
.into_iter()
.map(|(score, record)| Match { record, score })
.collect()
}
#[cfg(feature = "_semantic")]
fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
use std::collections::HashMap;
const K: f64 = 60.0;
let mut scored: HashMap<*const SchemaRecord, (f64, &SchemaRecord)> = HashMap::new();
for (rank, m) in fuzzy.iter().enumerate() {
scored.entry(m.record as *const _).or_insert((0.0, m.record)).0 += 1.0 / (K + rank as f64 + 1.0);
}
for (rank, m) in semantic.iter().enumerate() {
scored.entry(m.record as *const _).or_insert((0.0, m.record)).0 += 0.7 / (K + rank as f64 + 1.0);
}
let mut merged: Vec<Match> = scored
.into_values()
.map(|(score, record)| Match { record, score })
.collect();
merged.sort_by(|a, b| b.score.total_cmp(&a.score));
merged.truncate(limit);
merged
}
#[cfg(feature = "_semantic")]
fn spawn_background_warm(source: &str) {
if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
return;
}
if let Ok(exe) = std::env::current_exe() {
let _ = std::process::Command::new(exe)
.arg("--warm")
.arg(source)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
}
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 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 = if let Some(s) = cli.source.clone() {
s
} else if cli.warm {
match cli.query.clone() {
Some(s) => s,
None => load::discover()?,
}
} else {
load::discover()?
};
let records = load::load(&source)?;
if cli.warm {
#[cfg(feature = "_semantic")]
{
let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
eprintln!("gqls: warmed {n} record vector(s) into the cache");
return Ok(());
}
#[cfg(not(feature = "_semantic"))]
{
let _ = (&cli.model, cli.refresh);
anyhow::bail!("--warm needs a semantic build");
}
}
let query = cli
.query
.as_deref()
.ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
if cli.resolve {
return run_resolve(query, &source, &records, kind, cli.code.as_deref(), cli.limit, output);
}
let matches: Vec<Match> = if cli.fuzzy {
fuzzy_matches(query, &records, kind, cli.limit)
} else if cli.semantic {
#[cfg(feature = "_semantic")]
{
semantic_matches(query, &records, kind, &cli)
}
#[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 {
let fuzzy = fuzzy_matches(query, &records, kind, cli.limit);
#[cfg(feature = "_semantic")]
{
if crate::semantic::is_cached(&records, cli.model.as_deref()) {
let semantic = semantic_matches(query, &records, kind, &cli);
combine(fuzzy, semantic, cli.limit)
} else {
spawn_background_warm(&source);
eprintln!(
"gqls: warming the semantic index in the background — the next run also \
ranks by meaning (--semantic to embed now, --fuzzy to skip)"
);
fuzzy
}
}
#[cfg(not(feature = "_semantic"))]
{
let _ = (&cli.model, cli.refresh);
fuzzy
}
};
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,
source: &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 schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
.then(|| std::path::Path::new(source))
.filter(|p| p.exists());
let hits = crate::resolve::resolve(top.record, code, schema_path, 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(", "))
}
}