use std::collections::HashMap;
use std::env;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
use super::readout::parse_json;
use super::{Error, Result};
use crate::contract::{EXIT_ERROR, EXIT_MATCHED, EXIT_NO_MATCH, Match};
use crate::request::SearchRequest;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const UNSUPPORTED_MARKERS: &[&str] = &[
"unsupported",
"use ripgrep",
"use rg for this",
"linear-time syntax",
"not yet implemented",
];
const MALFORMED_MARKER: &str = "no engine here compiles it";
pub fn binary() -> Result<PathBuf> {
binary_named("gist", "GIST_BIN")
}
pub fn binary_named(name: &'static str, env_var: &'static str) -> Result<PathBuf> {
static CACHE: OnceLock<Mutex<HashMap<&'static str, PathBuf>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(map) = cache.lock()
&& let Some(found) = map.get(name)
{
return Ok(found.clone());
}
let resolved = resolve(name, env_var)?;
if let Ok(mut map) = cache.lock() {
map.insert(name, resolved.clone());
}
Ok(resolved)
}
fn resolve(name: &str, env_var: &str) -> Result<PathBuf> {
if let Some(raw) = env::var_os(env_var) {
let p = expand_tilde(&raw);
if p.is_file() {
return Ok(p);
}
return Err(Error::NotFound(format!(
"{env_var}={} is not a file",
p.display()
)));
}
let looked = candidates(name);
if let Some(found) = looked.iter().find(|p| p.is_file()) {
return Ok(found.clone());
}
if let Some(p) = which(name) {
return Ok(p);
}
Err(Error::NotFound(unfound(name, env_var, &looked)))
}
const ASCENT: usize = 16;
fn anchors() -> Vec<PathBuf> {
let mut from = Vec::with_capacity(2);
if let Ok(cwd) = env::current_dir() {
from.push(cwd);
}
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if !from.contains(&manifest) {
from.push(manifest);
}
from
}
fn candidates(name: &str) -> Vec<PathBuf> {
let built = |dir: &Path| dir.join("zig-out").join("bin").join(name);
let (mut own, mut siblings) = (Vec::new(), Vec::new());
for anchor in anchors() {
for dir in anchor.ancestors().take(ASCENT) {
own.push(built(dir));
if dir.file_name().is_some_and(|base| base == name) || !dir.join("build.zig").is_file()
{
continue;
}
if let Some(sibling) = dir.parent().map(|up| up.join(name))
&& sibling.join("build.zig").is_file()
{
siblings.push(built(&sibling));
}
}
}
own.append(&mut siblings);
once_each(own)
}
fn once_each(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut kept: Vec<PathBuf> = Vec::with_capacity(paths.len());
for p in paths {
if !kept.contains(&p) {
kept.push(p);
}
}
kept
}
fn unfound(name: &str, env_var: &str, looked: &[PathBuf]) -> String {
let rungs: String = if looked.is_empty() {
"\n\t(nowhere — no anchor directory could be read)".to_owned()
} else {
looked
.iter()
.map(|p| format!("\n\t{}", p.display()))
.collect()
};
format!(
"no `{name}` binary: {env_var} is unset, `{name}` is not on PATH, and no build exists at \
any of:{rungs}\nbuild one with `zig build -Doptimize=ReleaseFast` in the {name} checkout"
)
}
fn expand_tilde(raw: &std::ffi::OsStr) -> PathBuf {
raw.to_string_lossy()
.strip_prefix("~/")
.and_then(|rest| env::var_os("HOME").map(|home| PathBuf::from(home).join(rest)))
.unwrap_or_else(|| PathBuf::from(raw))
}
fn which(name: &str) -> Option<PathBuf> {
let paths = env::var_os("PATH")?;
env::split_paths(&paths)
.map(|d| d.join(name))
.find(|p| p.is_file())
}
struct Output {
code: i32,
stdout: String,
stderr: String,
}
pub fn capture(bin: &Path, args: &[String], cwd: Option<&Path>) -> Result<String> {
Ok(both(bin, args, cwd)?.0)
}
pub fn both(bin: &Path, args: &[String], cwd: Option<&Path>) -> Result<(String, String)> {
let mut cmd = Command::new(bin);
cmd.args(args);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let out = check(spawn_with_timeout(cmd, DEFAULT_TIMEOUT)?, bin)?;
Ok((out.stdout, out.stderr))
}
fn check(out: Output, bin: &Path) -> Result<Output> {
let name = bin
.file_name()
.map_or_else(|| "gist".to_owned(), |n| n.to_string_lossy().into_owned());
if out.code == EXIT_ERROR {
let stderr = out.stderr.trim();
let low = stderr.to_lowercase();
if low.contains(MALFORMED_MARKER) {
return Err(Error::BadPattern(nonempty(stderr, "malformed pattern")));
}
if UNSUPPORTED_MARKERS.iter().any(|m| low.contains(m)) {
return Err(Error::UnsupportedPattern(nonempty(
stderr,
"unsupported pattern",
)));
}
return Err(Error::Failed(nonempty(stderr, &format!("{name} exited 2"))));
}
if out.code != EXIT_MATCHED && out.code != EXIT_NO_MATCH {
return Err(Error::Failed(format!(
"{name} exited {}: {}",
out.code,
out.stderr.trim()
)));
}
Ok(out)
}
fn nonempty(s: &str, fallback: &str) -> String {
if s.is_empty() {
fallback.to_owned()
} else {
s.to_owned()
}
}
fn invoke(tail: &[&str], request: &SearchRequest) -> Result<Output> {
let bin = binary()?;
let mut cmd = Command::new(&bin);
cmd.arg("rg");
cmd.args(request.to_argv());
cmd.args(tail);
cmd.arg("--regexp").arg(&request.pattern);
cmd.args(&request.paths);
if let Some(dir) = &request.cwd {
cmd.current_dir(dir);
}
check(spawn_with_timeout(cmd, request.timeout)?, &bin)
}
const DRAIN_GRACE: Duration = Duration::from_millis(250);
const POLL: Duration = Duration::from_millis(5);
struct Reader {
bytes: Arc<Mutex<Vec<u8>>>,
thread: thread::JoinHandle<()>,
}
impl Reader {
fn spawn<R: Read + Send + 'static>(pipe: Option<R>) -> Self {
let bytes = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&bytes);
let thread = thread::spawn(move || {
let Some(mut pipe) = pipe else { return };
let mut buf = [0_u8; 8192];
while let Ok(n) = pipe.read(&mut buf) {
if n == 0 {
break;
}
if let Ok(mut sink) = sink.lock() {
sink.extend_from_slice(&buf[..n]);
}
}
});
Self { bytes, thread }
}
fn settle(self, deadline: Instant) -> String {
while !self.thread.is_finished() && Instant::now() < deadline {
thread::sleep(POLL);
}
self.bytes.lock().map_or_else(
|_| String::new(),
|b| String::from_utf8_lossy(&b).into_owned(),
)
}
}
fn spawn_with_timeout(mut cmd: Command, timeout: Duration) -> Result<Output> {
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let out_reader = Reader::spawn(child.stdout.take());
let err_reader = Reader::spawn(child.stderr.take());
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
let grace = Instant::now() + DRAIN_GRACE;
let _ = out_reader.settle(grace);
let _ = err_reader.settle(grace);
return Err(Error::Failed(format!(
"gist timed out after {}s",
timeout.as_secs()
)));
}
thread::sleep(POLL);
};
let grace = Instant::now() + DRAIN_GRACE;
Ok(Output {
code: status.code().unwrap_or(EXIT_ERROR),
stdout: out_reader.settle(grace),
stderr: err_reader.settle(grace),
})
}
pub fn run(request: &SearchRequest) -> Result<Vec<Match>> {
Ok(parse_json(&invoke(&["--json"], request)?.stdout))
}
pub fn files(request: &SearchRequest) -> Result<Vec<String>> {
let out = invoke(&["-l"], request)?;
let mut paths: Vec<String> = out
.stdout
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect();
paths.sort();
Ok(paths)
}
pub fn count(request: &SearchRequest) -> Result<usize> {
let out = invoke(&["--count", "--no-filename"], request)?;
Ok(out
.stdout
.lines()
.filter_map(|l| l.trim().parse::<usize>().ok())
.sum())
}
pub fn status() -> Result<String> {
let bin = binary()?;
let mut cmd = Command::new(&bin);
cmd.arg("status");
Ok(spawn_with_timeout(cmd, DEFAULT_TIMEOUT)?.stdout)
}
pub fn lifecycle(bin: &'static str, env_var: &'static str, args: &[&str]) -> Result<String> {
let path = binary_named(bin, env_var)?;
let mut cmd = Command::new(&path);
cmd.args(args);
Ok(check(spawn_with_timeout(cmd, DEFAULT_TIMEOUT)?, &path)?.stdout)
}
pub fn version() -> Result<String> {
let bin = binary()?;
let mut cmd = Command::new(&bin);
cmd.arg("--version");
let out = spawn_with_timeout(cmd, DEFAULT_TIMEOUT)?;
let banner = if out.stdout.trim().is_empty() {
out.stderr
} else {
out.stdout
};
Ok(banner
.split_whitespace()
.last()
.unwrap_or_default()
.to_owned())
}