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 (--semantic, 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(short = 'D', long)]
no_description: 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>,
#[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
header: Vec<String>,
#[arg(short, long, conflicts_with = "quiet")]
verbose: bool,
#[arg(short, long)]
quiet: bool,
}
#[derive(Clone, Copy)]
enum Output {
Text { descriptions: bool },
Json,
Ndjson,
}
struct Match<'a> {
record: &'a SchemaRecord,
score: f64,
}
fn fuzzy_matches<'a>(
query: &str,
records: &'a [SchemaRecord],
kind: Option<Kind>,
parent: Option<&str>,
) -> (Vec<Match<'a>>, bool) {
let hits = search::search(query, records, kind, parent);
let named = search::named_hit(query, &hits);
let matches = hits
.into_iter()
.map(|h| Match {
record: h.record,
score: h.score as f64,
})
.collect();
(matches, named)
}
#[cfg(feature = "_semantic")]
fn semantic_matches<'a>(
query: &str,
records: &'a [SchemaRecord],
kind: Option<Kind>,
parent: Option<&str>,
cli: &Cli,
) -> Vec<Match<'a>> {
crate::semantic::search(
query,
records,
kind,
parent,
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<&str, (f64, &SchemaRecord)> = HashMap::new();
for (rank, m) in fuzzy.iter().enumerate() {
scored
.entry(m.record.path.as_str())
.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.path.as_str())
.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, headers: &[String]) {
if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
return;
}
if !claim_warm_lock(source) {
return;
}
if let Ok(exe) = std::env::current_exe() {
let mut cmd = std::process::Command::new(exe);
cmd.arg("--warm").arg(source);
for h in headers {
cmd.arg("--header").arg(h);
}
let _ = cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
}
#[cfg(feature = "_semantic")]
fn claim_warm_lock(source: &str) -> bool {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::Duration;
const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
let dir = crate::paths::temp_dir();
let mut h = DefaultHasher::new();
source.hash(&mut h);
let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
if let Ok(meta) = std::fs::metadata(&lock) {
if let Ok(modified) = meta.modified() {
if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
return false; }
}
}
let _ = std::fs::create_dir_all(&dir);
std::fs::write(&lock, []).is_ok()
}
fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
raw.iter()
.map(|h| {
let (name, value) = h
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
Ok((name.trim().to_string(), value.trim().to_string()))
})
.collect()
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
crate::logging::init(cli.verbose, cli.quiet);
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 {
let introspect = crate::load::introspect::clear_cache();
let records = crate::load::record_cache::clear();
#[cfg(feature = "_semantic")]
let vectors = crate::semantic::clear_cache();
#[cfg(not(feature = "_semantic"))]
let vectors = 0;
crate::status!("cleared {} cached file(s)", introspect + records + vectors);
return Ok(());
}
let output = if cli.json {
Output::Json
} else if cli.ndjson {
Output::Ndjson
} else {
Output::Text {
descriptions: !cli.no_description,
}
};
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 load_opts = load::LoadOptions {
headers: parse_headers(&cli.header)?,
refresh: cli.refresh,
};
let t_load = std::time::Instant::now();
let records = load::load(&source, &load_opts)?;
crate::detail!(
"loaded {} records in {:.1?}",
records.len(),
t_load.elapsed()
);
if cli.warm {
#[cfg(feature = "_semantic")]
{
let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
crate::status!("cached vectors for {n} record(s)");
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)"))?;
let spaced = search::spaced_qualifier(query, &records);
let loose = spaced.is_some();
let query = spaced.as_deref().unwrap_or(query);
if loose {
crate::detail!("two-word query names a type — searching as {query:?}");
}
let parent = search::parent_filter(query, &records);
if let Some(p) = parent {
let (_, qualifier) = search::score::parse_qualified(query);
if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
crate::detail!("qualifier {p:?} names a type — restricting to its members");
} else {
crate::status!(
"no type named {:?} — using closest match {p:?}",
qualifier.unwrap_or_default()
);
}
}
if cli.resolve {
return run_resolve(
query,
&source,
&records,
kind,
parent,
cli.code.as_deref(),
cli.limit,
output,
);
}
let t_rank = std::time::Instant::now();
let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent);
let total = fuzzy.len();
fuzzy.truncate(cli.limit);
(fuzzy, total)
} else if cli.semantic {
#[cfg(feature = "_semantic")]
{
let matches = semantic_matches(query, &records, kind, parent, &cli);
let total = matches.len();
(matches, total)
}
#[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 (mut fuzzy, named) = fuzzy_matches(query, &records, kind, parent);
let total = fuzzy.len();
fuzzy.truncate(cli.limit);
#[cfg(feature = "_semantic")]
{
if named && !loose {
crate::detail!(
"strong name match — semantic ranking skipped (--semantic to force)"
);
(fuzzy, total)
} else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
let semantic = semantic_matches(query, &records, kind, parent, &cli);
(combine(fuzzy, semantic, cli.limit), total)
} else {
spawn_background_warm(&source, &cli.header);
crate::status!(
"building the semantic index in the background; next run ranks by \
meaning (--semantic to wait, --fuzzy to skip)"
);
(fuzzy, total)
}
}
#[cfg(not(feature = "_semantic"))]
{
let _ = (&cli.model, cli.refresh, named, loose);
(fuzzy, total)
}
};
crate::detail!("ranked in {:.1?}", t_rank.elapsed());
if matches.is_empty() {
crate::status!("no matches for {query:?}");
}
output.write_matches(&matches)?;
if total > matches.len() {
crate::detail!(
"{total} matches; showing top {} (-l to adjust)",
matches.len()
);
}
Ok(())
}
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 { descriptions } => print_text(matches, descriptions),
}
Ok(())
}
}
const DESCRIPTION_WIDTH: usize = 72;
fn print_text(matches: &[Match], descriptions: bool) {
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 {
""
};
let desc = if descriptions {
r.description
.as_deref()
.map(summarize)
.filter(|d| !d.is_empty())
.map(|d| format!(" — {d}"))
.unwrap_or_default()
} else {
String::new()
};
println!(
"{path:<width$}{ret} [{kind}]{dep}{desc}",
kind = r.kind.as_str()
);
}
}
fn summarize(description: &str) -> String {
let mut out = String::new();
for (i, word) in description.split_whitespace().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(word);
if out.chars().count() > DESCRIPTION_WIDTH {
let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
return format!("{}…", kept.trim_end());
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn run_resolve(
query: &str,
source: &str,
records: &[SchemaRecord],
kind: Option<Kind>,
parent: Option<&str>,
code: Option<&str>,
limit: usize,
output: Output,
) -> Result<()> {
if code.is_none() {
crate::status!("searching code in the current directory (--code to search elsewhere)");
}
let Some(top) = search::search(query, records, kind, parent)
.into_iter()
.next()
else {
anyhow::bail!("no schema entity matches {query:?} to resolve");
};
crate::status!("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() {
crate::status!(
"no code definition found for {} (-v shows what was tried)",
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(", "))
}
}
#[cfg(test)]
mod tests {
use super::{summarize, DESCRIPTION_WIDTH};
#[test]
fn collapses_block_descriptions_to_one_line() {
assert_eq!(summarize(" Look up\n a user.\n"), "Look up a user.");
assert_eq!(summarize(" "), "");
}
#[test]
fn elides_past_the_width() {
let long = "word ".repeat(60);
let out = summarize(&long);
assert!(out.ends_with('…'), "{out}");
assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
}
#[test]
fn keeps_a_description_that_fits() {
let s = "An account.";
assert_eq!(summarize(s), s);
}
}