use std::collections::HashSet;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap::{CommandFactory, Parser};
use clap_complete::Shell;
use crate::store::Store;
#[derive(Parser)]
#[command(
name = "rq",
version,
about = "Ranked definition lookup — the one place a symbol is defined, first.",
long_about = "rq finds where a symbol is defined and ranks the one you most \
likely meant to the top — not every match.\n\n\
Search is the default action; operations are flags, not subcommands, so every \
word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
your current repo and recently-active files, and learns from the results you open \
(see RECORDING below). Run `rq <query> --explain` to see the score behind each result.",
after_help = "EXAMPLES:\n \
rq thing search for a definition named or like \"thing\"\n \
rq wibble --explain same, plus the score behind each result\n \
rq thing --json machine-readable results (for editors/agents)\n \
rq thing --no-record search without recording it (speculative/agent queries)\n \
rq thing --no-wait answer now from the committed index; don't block on a rebuild\n \
rq thing --wait 2s ...or wait up to a bounded time for the index to warm\n \
rq thing app/web restrict to a directory (rg-style)\n \
rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
rq class Widget a leading kind keyword is shorthand for -k\n \
rq --symbols FILE outline a file's definitions, in line order\n \
rq thing -x rust restrict to a language (ruby/rust/go/python/ts/js)\n \
rq -o thing open the best match in your editor (and record it)\n \
rq --index index the current repository\n \
rq --status show indexing coverage\n \
rq --drop remove this repo's index (opposite of --index)\n\n\
SHORT FLAGS (easy to misread):\n \
-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
RECORDING (editor/shell hook):\n \
rq --record --file <path> --line <n> <query>\n \
Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
automatically on the first search in a git repo. On a large, cold repo a search \
keeps indexing until it can answer rather than reporting a premature \"no \
matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
)]
struct Cli {
#[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
target: Option<String>,
#[arg(value_name = "PATH")]
dirs: Vec<String>,
#[arg(short = 'e', long)]
explain: bool,
#[arg(long)]
no_record: bool,
#[arg(long = "no-wait")]
no_wait: bool,
#[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
wait: Option<Duration>,
#[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
open: bool,
#[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
show: bool,
#[arg(short = 'j', long)]
json: bool,
#[arg(short = 'J', long, conflicts_with = "json")]
ndjson: bool,
#[arg(short = 'p', long, value_name = "DIR")]
path: Vec<String>,
#[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
limit: usize,
#[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
kind: Vec<String>,
#[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
lang: Vec<String>,
#[arg(long = "all-repos")]
all_repos: bool,
#[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
index: Option<Option<String>>,
#[arg(long, conflicts_with_all = ["index", "record"])]
status: bool,
#[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
symbols: Option<String>,
#[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
drop: bool,
#[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
record: bool,
#[arg(long)]
file: Option<String>,
#[arg(long)]
line: Option<i64>,
#[arg(long, default_value = "select")]
event: String,
#[arg(long, hide = true, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["index", "status", "record", "drop", "symbols", "open", "show"])]
warm: Option<Option<String>>,
#[arg(long, value_name = "SHELL")]
completions: Option<Shell>,
#[arg(short = 'v', long)]
verbose: bool,
#[arg(long)]
profile: bool,
#[arg(long, value_name = "N", default_value_t = 0)]
jobs: usize,
}
pub fn run() -> ExitCode {
let cli = Cli::parse();
crate::trace::enable_from(cli.verbose);
crate::profile::enable_from(cli.profile);
crate::index::set_parse_jobs(cli.jobs);
if let Some(shell) = cli.completions {
clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
return ExitCode::SUCCESS;
}
if let Some(path) = &cli.index {
let out = output_format(&cli);
return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
}
if let Some(path) = &cli.warm {
return cmd_warm(path.as_deref());
}
if cli.status {
return cmd_status(output_format(&cli));
}
if cli.drop {
let out = output_format(&cli);
return cmd_drop(cli.target, out);
}
if cli.record {
if !matches!(cli.event.as_str(), "select" | "open") {
return fail(format_args!(
"rq --record: unknown --event {:?} (expected select or open)",
cli.event
));
}
let file = cli.file.expect("--record requires --file");
return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
}
let out = output_format(&cli);
let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
if let Some(file) = &cli.symbols {
return cmd_symbols(file, &kinds, &langs, out);
}
let mut paths = cli.path.clone();
match cli.target {
Some(target) => {
let query = if cli.kind.is_empty() {
let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
if let Some(k) = kw {
kinds.push(k.to_string());
}
paths.extend(dirs);
query
} else {
paths.extend(cli.dirs.clone());
target
};
let mut session = match Session::open() {
Ok(s) => s,
Err(code) => return code,
};
cmd_search(
&mut session,
&SearchArgs {
query: &query,
explain: cli.explain,
out,
paths: &paths,
kinds: &kinds,
langs: &langs,
want: cli.limit,
no_record: cli.no_record,
no_wait: cli.no_wait,
wait: cli.wait,
open: cli.open,
all_repos: cli.all_repos,
show: cli.show,
batch: false,
},
)
}
None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
None => {
let _ = Cli::command().print_long_help();
ExitCode::SUCCESS
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Output {
Text,
Json,
Ndjson,
}
fn output_format(cli: &Cli) -> Output {
if cli.ndjson {
Output::Ndjson
} else if cli.json {
Output::Json
} else {
Output::Text
}
}
const PATH_HEADROOM: usize = 200;
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
struct SearchArgs<'a> {
query: &'a str,
explain: bool,
out: Output,
paths: &'a [String],
kinds: &'a [String],
langs: &'a [String],
want: usize,
no_record: bool,
no_wait: bool,
wait: Option<Duration>,
open: bool,
all_repos: bool,
batch: bool,
show: bool,
}
struct Session {
store: Store,
cwd: Option<PathBuf>,
cwd_is_git: bool,
root: Option<PathBuf>,
active_paths: Vec<String>,
branch_refresh: Option<BranchRefresh>,
identity: Option<String>,
coverage: Option<String>,
}
impl Session {
fn open() -> std::result::Result<Session, ExitCode> {
let open_span = crate::profile::span("store open");
let store = match open_store() {
Ok(s) => s,
Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
};
drop(open_span);
let git_span = crate::profile::span("setup: git root");
let cwd = std::env::current_dir().ok();
let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
let root = cwd
.as_deref()
.map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
drop(git_span);
let mut branch_span = crate::profile::span("setup: branch files");
let (active_paths, branch_refresh) = match &root {
Some(c) if cwd_is_git => cached_branch_files(&store, c),
_ => (Vec::new(), None),
};
branch_span.note(|| {
let how = if branch_refresh.is_some() {
"cached, refreshing alongside"
} else {
"cached"
};
format!("{} changed, {how}", active_paths.len())
});
drop(branch_span);
let mut identity_span = crate::profile::span("setup: identity");
let identity = root.as_deref().map(|c| resolve_identity(&store, c));
let coverage = identity
.as_deref()
.and_then(|id| store.coverage_status(id).ok())
.flatten();
identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
drop(identity_span);
Ok(Session {
store,
cwd,
cwd_is_git,
root,
active_paths,
branch_refresh,
identity,
coverage,
})
}
}
fn cmd_batch(
cli: &Cli,
out: Output,
paths: &[String],
kinds: &[String],
langs: &[String],
) -> ExitCode {
if out == Output::Json {
return fail(format_args!(
"rq: --json can't frame a stream of queries — use --ndjson (-J), \
where each line carries the query it answers"
));
}
if cli.open || cli.show {
return fail(format_args!(
"rq: --open and --show act on a single result, not a stream of queries"
));
}
use std::io::BufRead;
let queries: Vec<String> = std::io::stdin()
.lock()
.lines()
.map_while(std::result::Result::ok)
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if queries.is_empty() {
let _ = Cli::command().print_long_help();
return ExitCode::SUCCESS;
}
let mut session = match Session::open() {
Ok(s) => s,
Err(code) => return code,
};
if !cli.no_wait
&& session.coverage.as_deref() != Some("complete")
&& let Some(root) = session.root.clone()
{
{
let budget = cli.wait.unwrap_or_else(wait_budget);
crate::trace!(
"batch: warming {} queries' worth of index first",
queries.len()
);
let active = session.active_paths.clone();
let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
session.coverage = session
.identity
.as_deref()
.and_then(|id| session.store.coverage_status(id).ok())
.flatten();
}
}
let mut worst = ExitCode::SUCCESS;
let mut any_hit = false;
for query in &queries {
let code = cmd_search(
&mut session,
&SearchArgs {
query,
explain: cli.explain,
out,
paths,
kinds,
langs,
want: cli.limit,
no_record: cli.no_record,
no_wait: true,
wait: cli.wait,
open: false,
all_repos: cli.all_repos,
show: false,
batch: true,
},
);
if code == ExitCode::SUCCESS {
any_hit = true;
} else {
worst = code;
}
}
if any_hit { ExitCode::SUCCESS } else { worst }
}
fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
let &SearchArgs {
query,
out,
want,
no_record,
no_wait,
wait,
open,
all_repos,
show,
..
} = args;
let wait_budget = wait.unwrap_or_else(wait_budget);
let no_wait = no_wait || wait_budget.is_zero();
let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
want
} else {
(want * 20).max(PATH_HEADROOM)
};
let _timer = crate::trace::Timer::start("search done");
let profile_started = std::time::Instant::now();
let t_setup = std::time::Instant::now();
let setup_span = crate::profile::span("setup");
let Session {
store,
cwd,
cwd_is_git,
root,
active_paths,
branch_refresh,
identity,
coverage,
} = session;
let cwd_is_git = *cwd_is_git;
let known = coverage.is_some();
let warming_ok = cwd_is_git || known;
if crate::trace::enabled() {
crate::trace!(
"query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
root.as_deref().map_or("?".into(), crate::trace::abbrev),
identity.as_deref().unwrap_or("none"),
coverage.as_deref().unwrap_or("none"),
active_paths.len(),
);
}
let repo_span = crate::profile::span("setup: repo state");
let current = identity
.as_deref()
.and_then(|id| store.repository_id(id).ok().flatten());
let only_repo = if all_repos { None } else { current };
let active = crate::search::ActiveFiles::new(active_paths.clone());
drop(repo_span);
let warm_span = crate::profile::span("setup: warm decision");
let warm_budget = if warm_detach_enabled() {
answer_warm_budget()
} else {
answer_warm_budget() + deferred_warm_budget()
};
let was_warming = coverage.as_deref() != Some("complete");
let indexed_head = (!was_warming)
.then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
.flatten();
let staleness = (!was_warming && warming_ok && !args.batch)
.then(|| root.clone())
.flatten()
.map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
let want_warm = warming_ok && was_warming && root.is_some();
let block = want_warm && was_warming && !no_wait;
let progress_ui = block && show_progress(out, stderr_interactive());
let indexer_budget = if block { wait_budget } else { warm_budget };
if progress_ui {
install_interrupt_handler();
}
let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
crate::trace!(
"background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
crate::index::parse_jobs()
);
let root = root.clone().expect("checked");
let active = active_paths.clone();
let q = query.to_string();
let warm_done = std::sync::Arc::clone(&warm_done);
std::thread::spawn(move || {
if let Ok(mut idx) = open_store() {
let _ = if block {
crate::index::index_budgeted_cancellable(
&mut idx,
&root,
&active,
indexer_budget,
Some(&q),
&INTERRUPTED,
)
} else {
crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
};
}
warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
})
});
crate::trace!(
"setup (open + repo detect + warm decision): {} ms",
t_setup.elapsed().as_millis()
);
let poll_start = std::time::Instant::now();
let deadline = if progress_ui {
None
} else if block {
Some(poll_start + wait_budget)
} else {
Some(poll_start + answer_warm_budget())
};
drop(warm_span);
let polling = indexer.is_some() && was_warming;
drop(setup_span);
let mut query_span = crate::profile::span("query");
let label = repo_label(root.as_deref());
let mut drew_progress = false;
let mut last_draw = poll_start;
let mut hits = loop {
match crate::search::search(store, query, current, only_repo, &active, limit) {
Ok(h) => {
let confident = h.first().is_some_and(|hit| {
hit.features
.iter()
.any(|f| matches!(f.name, "exact" | "prefix"))
});
let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
if !polling || confident || warm_finished || stopped || timed_out {
break h;
}
if progress_ui
&& poll_start.elapsed() >= HEADS_UP_DELAY
&& last_draw.elapsed() >= PROGRESS_REDRAW
{
draw_progress(store, identity.as_deref(), &label);
drew_progress = true;
last_draw = std::time::Instant::now();
}
}
Err(e) => {
if let Some(h) = indexer {
let _ = h.join();
}
return fail(format_args!("rq: {e}"));
}
}
std::thread::sleep(POLL_INTERVAL);
};
query_span.note(|| {
if polling {
"polled a warming index".to_string()
} else {
String::new()
}
});
drop(query_span);
if drew_progress {
clear_progress();
}
let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
if !hits.is_empty() && revalidate_top(store, &hits) {
hits = crate::search::search(store, query, current, only_repo, &active, limit)
.unwrap_or_default();
}
if !hits.iter().any(strong)
&& indexer.is_none()
&& coverage.is_none()
&& let Some(root) = &root
{
let tail = live_fallback(root, query, limit);
hits = crate::search::merge(hits, tail, limit);
}
apply_gates(query, &mut hits);
apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
if hits.is_empty() {
if block {
INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Some(h) = indexer {
let _ = h.join();
}
let mut incomplete = (block || no_wait)
&& identity
.as_deref()
.and_then(|id| store.coverage_status(id).ok().flatten())
.as_deref()
!= Some("complete");
incomplete |= settle_warm(
store,
staleness,
was_warming,
warming_ok,
root.as_deref(),
active_paths,
query,
warm_budget,
no_wait,
identity.as_deref(),
);
return no_match_code(out, query, interrupted, incomplete);
}
for hit in &mut hits {
hit.signature = read_signature(
store,
&hit.repo_identity,
&hit.file,
hit.line,
cwd.as_deref(),
);
}
attach_confidence(&mut hits);
if show && let Some(code) = show_top_definition(store, &mut hits, query, out, cwd.as_deref()) {
return code;
}
if open {
return finish_open(store, &hits, query, current, root.as_deref(), no_record);
}
if let Some(code) = render_hits(args, &hits) {
return code;
}
if crate::profile::enabled() {
let total = profile_started.elapsed();
if args.out == Output::Text {
for line in crate::profile::report(total) {
eprintln!("{line}");
}
} else {
eprintln!("{}", crate::profile::json(total));
}
}
if let Some(refresh) = branch_refresh.take() {
refresh.store(store);
}
if !no_record {
let _ = store.record_event(
"search",
Some(&query.to_ascii_lowercase()),
current,
None,
None,
None,
);
}
deferred_maintenance(store);
if block {
INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Some(h) = indexer {
let _ = h.join();
}
let _ = settle_warm(
store,
staleness,
was_warming,
warming_ok,
root.as_deref(),
active_paths,
query,
warm_budget,
no_wait,
identity.as_deref(),
);
ExitCode::SUCCESS
}
fn maybe_detach_warm(
store: &Store,
want_warm: bool,
changed: bool,
root: Option<&std::path::Path>,
identity: Option<&str>,
) {
if !warm_detach_enabled() || !want_warm {
return;
}
let (Some(root), Some(id)) = (root, identity) else {
return;
};
if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
return; }
spawn_detached_warm(root);
}
fn spawn_detached_warm(root: &std::path::Path) {
use std::os::unix::process::CommandExt;
let Ok(exe) = std::env::current_exe() else {
return;
};
let mut cmd = std::process::Command::new(exe);
cmd.arg("--warm")
.arg(root)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.process_group(0);
match cmd.spawn() {
Ok(child) => crate::trace!(
"background warm (detached): pid {} for {}",
child.id(),
crate::trace::abbrev(root)
),
Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
}
}
const WARM_LOCK_TTL_SECS: i64 = 600;
fn cmd_warm(path: Option<&str>) -> ExitCode {
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn setiopolicy_np(
iotype: libc::c_int,
scope: libc::c_int,
policy: libc::c_int,
) -> libc::c_int;
}
unsafe {
libc::nice(10);
#[cfg(target_os = "macos")]
setiopolicy_np(0, 0, 3);
}
let mut store = match open_store() {
Ok(s) => s,
Err(_) => return ExitCode::FAILURE,
};
let start = path
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
let root = crate::index::repo_root(&start).unwrap_or(start);
let identity = resolve_identity(&store, &root);
if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
&& pid != std::process::id()
&& unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
&& now_secs() - ts < WARM_LOCK_TTL_SECS
{
return ExitCode::SUCCESS;
}
let _ = store.set_warm_lock(&identity, std::process::id());
let deadline = std::time::Instant::now() + warm_bg_budget();
let active = crate::index::branch_changed_files(&root);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break;
}
let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
{
Ok(s) => s,
Err(_) => break,
};
if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
|| stats.files_indexed == 0
{
break;
}
}
let _ = store.clear_warm_lock(&identity);
ExitCode::SUCCESS
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
crate::trace!("empty → live (in-memory) scan of an untracked dir");
let deadline = std::time::Instant::now() + live_fallback_budget();
let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
if !h.is_empty() {
return h;
}
crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
}
fn strong(h: &crate::search::Hit) -> bool {
h.features
.iter()
.any(|f| matches!(f.name, "exact" | "prefix"))
}
fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
if hits.iter().any(strong) {
hits.retain(strong);
}
crate::search::apply_scope_gate(query, hits);
}
fn apply_post_filters(
args: &SearchArgs,
cwd: Option<&std::path::Path>,
root: Option<&std::path::Path>,
hits: &mut Vec<crate::search::Hit>,
) {
if !args.paths.is_empty() {
let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
let base = root.map_or_else(|| here.clone(), PathBuf::from);
let norm: Vec<String> = args
.paths
.iter()
.map(|p| repo_relative(&base, &here, p))
.collect();
hits.retain(|h| under_any(&h.file, &norm));
}
if !args.kinds.is_empty() {
hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
}
if !args.langs.is_empty() {
hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
}
if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
hits.truncate(args.want);
}
}
fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
let status = if interrupted {
"interrupted"
} else if incomplete {
"warming"
} else {
"no_match"
};
match out {
Output::Json | Output::Ndjson => {
let obj = serde_json::json!({ "status": status, "query": query });
let _ = emit_json(out, &obj); }
Output::Text if interrupted => {
eprintln!("rq: indexing interrupted — run again to finish")
}
Output::Text if incomplete => eprintln!(
"rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
),
Output::Text => eprintln!("no matches for {query:?}"),
}
if incomplete {
ExitCode::from(2)
} else {
ExitCode::FAILURE
}
}
fn attach_confidence(hits: &mut [crate::search::Hit]) {
let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
if t.is_none_or(|t| h.score > t) {
(Some(h.score), t)
} else if s.is_none_or(|s| h.score > s) {
(t, Some(h.score))
} else {
(t, s)
}
});
for hit in hits.iter_mut() {
let best_other = if Some(hit.score) == top { second } else { top };
hit.confidence = crate::search::confidence(
hit.score,
crate::search::match_quality(&hit.features),
best_other,
);
}
}
fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
let render_span = crate::profile::span("render");
if args.batch {
#[derive(serde::Serialize)]
struct Tagged<'a> {
query: &'a str,
#[serde(flatten)]
hit: &'a crate::search::Hit,
}
let rows: Vec<Tagged> = hits
.iter()
.map(|hit| Tagged {
query: args.query,
hit,
})
.collect();
if let Some(code) = emit_rows(args.out, &rows) {
return Some(code);
}
} else if let Some(code) = emit_rows(args.out, hits) {
return Some(code);
}
if args.out != Output::Text {
return None;
}
drop(render_span);
let color = match_color();
let c = color.as_deref();
let query = args.query;
if args.show {
eprintln!(
"rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
hits.len()
);
}
for hit in hits {
let name = hl(&hit.name, query, c);
let qualified = match &hit.parent {
Some(p) => format!("{name} · {p}"),
None => name,
};
println!(
"{}:{} {} {}",
hl_path(&hit.file, query, c),
hit.line,
hit.kind,
qualified
);
if let Some(sig) = &hit.signature {
println!(" {}", hl(sig, query, c));
}
if args.explain {
let parts: Vec<String> = hit
.features
.iter()
.map(|f| format!("{} {:.0}", f.name, f.value))
.collect();
println!(
" confidence {:.2} · score {:.0} = {}",
hit.confidence,
hit.score,
parts.join(" + ")
);
}
}
None
}
fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
use std::io::{IsTerminal, Write};
if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return hits.first();
}
let mut err = std::io::stderr();
let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
for (i, h) in hits.iter().enumerate() {
let _ = writeln!(
err,
" {}. {}:{} {} {}",
i + 1,
h.file,
h.line,
h.kind,
h.name
);
}
let _ = write!(err, "rq> ");
let _ = err.flush();
let mut line = String::new();
if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
return None; }
parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
}
fn parse_choice(input: &str, n: usize) -> Option<usize> {
let s = input.trim();
if s.is_empty() {
return Some(0);
}
let i = s.parse::<usize>().ok()?.checked_sub(1)?;
(i < n).then_some(i)
}
fn finish_open(
store: &mut Store,
hits: &[crate::search::Hit],
query: &str,
current: Option<i64>,
root: Option<&std::path::Path>,
no_record: bool,
) -> ExitCode {
let Some(hit) = choose_hit(hits) else {
return ExitCode::SUCCESS; };
if !no_record {
let _ = store.record_event(
"select",
Some(&query.to_ascii_lowercase()),
current,
Some(&hit.file),
Some(hit.line),
None,
);
deferred_maintenance(store);
}
let target = match root {
Some(r) => r.join(&hit.file),
None => PathBuf::from(&hit.file),
};
launch_editor(&target, hit.line)
}
fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
use std::os::unix::process::CommandExt;
let loc = format!("{}:{}", file.display(), line);
match open_command(file, line, &loc) {
Some((prog, args)) => {
let err = std::process::Command::new(&prog).args(&args).exec();
fail(format_args!("rq --open: cannot run {prog}: {err}"))
}
None => {
println!("{loc}");
ExitCode::SUCCESS
}
}
}
fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
let fstr = file.to_string_lossy().into_owned();
if let Some(t) = std::env::var_os("RQ_OPEN") {
let t = t.to_string_lossy();
let mut parts = t.split_whitespace().map(|p| {
p.replace("{file}", &fstr)
.replace("{line}", &line.to_string())
.replace("{}", loc)
});
if let Some(prog) = parts.next() {
return Some((prog, parts.collect()));
}
}
if on_path("code") {
return Some(("code".into(), vec!["--goto".into(), loc.into()]));
}
if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
let ed = ed.to_string_lossy().into_owned();
let l = ed.to_ascii_lowercase();
if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
.iter()
.any(|e| l.contains(e))
{
return Some((ed, vec![format!("+{line}"), fstr]));
}
return Some((ed, vec![fstr]));
}
None
}
fn on_path(prog: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
const BRANCH_FILES_TTL_SECS: i64 = 15;
struct BranchRefresh {
handle: std::thread::JoinHandle<Vec<String>>,
identity: String,
stamp: String,
}
impl BranchRefresh {
fn store(self, store: &Store) {
let Ok(files) = self.handle.join() else {
return;
};
let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
}
}
fn cached_branch_files(
store: &Store,
root: &std::path::Path,
) -> (Vec<String>, Option<BranchRefresh>) {
let identity = resolve_identity(store, root);
let stamp = crate::index::branch_files_stamp(root);
let cached = store.branch_files_get(&identity).ok().flatten();
let now = unix_now();
if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
return (files.clone(), None);
}
let owned_root = root.to_path_buf();
let refresh = BranchRefresh {
handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
identity,
stamp: stamp.clone(),
};
return (files.clone(), Some(refresh));
}
let files = crate::index::branch_changed_files(root);
if let Some(stamp) = stamp {
let _ = store.branch_files_set(&identity, &stamp, now, &files);
}
(files, None)
}
fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
let Some(head) = indexed_head else {
return true;
};
crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
}
#[allow(clippy::too_many_arguments)]
fn settle_warm(
store: &Store,
staleness: Option<std::thread::JoinHandle<bool>>,
was_warming: bool,
warming_ok: bool,
root: Option<&std::path::Path>,
active: &[String],
query: &str,
budget: Duration,
no_wait: bool,
identity: Option<&str>,
) -> bool {
let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
if changed
&& !no_wait
&& !warm_detach_enabled()
&& let Some(r) = root
&& let Ok(mut idx) = open_store()
{
crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
}
maybe_detach_warm(
store,
warming_ok && (was_warming || changed),
changed,
root,
identity,
);
changed && warm_detach_enabled()
}
fn answer_warm_budget() -> Duration {
env_budget("RQ_ANSWER_BUDGET_MS", 500)
}
fn deferred_warm_budget() -> Duration {
env_budget("RQ_DEFERRED_BUDGET_MS", 250)
}
fn live_fallback_budget() -> Duration {
env_budget("RQ_FALLBACK_BUDGET_MS", 250)
}
fn warm_bg_budget() -> Duration {
env_budget("RQ_WARM_BUDGET_MS", 20_000)
}
fn warm_detach_enabled() -> bool {
std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
}
fn wait_budget() -> Duration {
env_budget("RQ_WAIT_BUDGET_MS", 60_000)
}
fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
let s = s.trim();
let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
(n, 1.0)
} else if let Some(n) = s.strip_suffix('s') {
(n, 1_000.0)
} else if let Some(n) = s.strip_suffix('m') {
(n, 60_000.0)
} else {
(s, 1_000.0)
};
let val: f64 = num.trim().parse().map_err(|_| bad())?;
if !val.is_finite() || val < 0.0 {
return Err(bad());
}
Ok(Duration::from_millis((val * unit_ms).round() as u64))
}
static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
extern "C" fn on_sigint(_: libc::c_int) {
INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
}
fn install_interrupt_handler() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = on_sigint as *const () as usize;
libc::sigemptyset(&mut action.sa_mask);
libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
});
}
fn stderr_interactive() -> bool {
std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
}
fn show_progress(out: Output, interactive: bool) -> bool {
interactive && matches!(out, Output::Text)
}
fn repo_label(root: Option<&std::path::Path>) -> String {
root.and_then(|r| r.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".into())
}
fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
let files = identity
.and_then(|id| store.repository_id(id).ok().flatten())
.and_then(|rid| store.repo_totals(rid).ok())
.map_or(0, |(f, _)| f);
eprint!("\r\x1b[Krq: indexing {label}… {files} files");
let _ = std::io::stderr().flush();
}
fn clear_progress() {
eprint!("\r\x1b[K");
let _ = std::io::stderr().flush();
}
fn env_budget(var: &str, default_ms: u64) -> Duration {
let ms = std::env::var(var)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default_ms);
Duration::from_millis(ms)
}
const AGGREGATE_BATCH: usize = 256;
const KEEP_RECENT_EVENTS: i64 = 200;
fn deferred_maintenance(store: &mut Store) {
let _ = store.aggregate_events(AGGREGATE_BATCH);
let _ = store.prune_events(KEEP_RECENT_EVENTS);
}
fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
let mut store = match open_store() {
Ok(s) => s,
Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
};
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let identity = crate::index::detect_identity(&cwd).to_string();
let repo_id = store.repository_id(&identity).ok().flatten();
let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
None => file.to_string(),
};
let query_norm = query.map(|q| q.to_ascii_lowercase());
if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
{
return fail(format_args!("rq record: {e}"));
}
deferred_maintenance(&mut store);
ExitCode::SUCCESS
}
fn hit_file_roots(
store: &Store,
repo_identity: &str,
cwd: Option<&std::path::Path>,
) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = store
.repository_id(repo_identity)
.ok()
.flatten()
.map(|id| store.checkout_roots(id).unwrap_or_default())
.unwrap_or_default()
.into_iter()
.map(PathBuf::from)
.collect();
if let Some(c) = cwd {
let c = c.to_path_buf();
if !roots.contains(&c) {
roots.push(c);
}
}
roots
}
fn read_signature(
store: &Store,
repo_identity: &str,
file: &str,
line: i64,
cwd: Option<&std::path::Path>,
) -> Option<String> {
hit_file_roots(store, repo_identity, cwd)
.into_iter()
.find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
}
const SHOW_CONFIDENCE: f64 = 0.85;
fn show_top_definition(
store: &Store,
hits: &mut [crate::search::Hit],
query: &str,
out: Output,
cwd: Option<&std::path::Path>,
) -> Option<ExitCode> {
let top = hits.first()?;
if top.confidence < SHOW_CONFIDENCE {
return None; }
let end = top.end_line.unwrap_or(top.line);
let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
hits[0].body = body;
let top = &hits[0];
match out {
Output::Json | Output::Ndjson => {
return Some(emit_json(out, top));
}
Output::Text => {
let color = match_color();
let c = color.as_deref();
let name = hl(&top.name, query, c);
let qualified = match &top.parent {
Some(p) => format!("{name} · {p}"),
None => name,
};
println!(
"{}:{} {} {}",
hl_path(&top.file, query, c),
top.line,
top.kind,
qualified
);
match (&top.body, &top.signature) {
(Some(body), _) => println!("{body}"),
(None, Some(sig)) => println!("{sig}"),
(None, None) => {}
}
}
}
Some(ExitCode::SUCCESS)
}
fn read_span(
store: &Store,
repo_identity: &str,
file: &str,
start: i64,
end: i64,
cwd: Option<&std::path::Path>,
) -> Option<String> {
hit_file_roots(store, repo_identity, cwd)
.into_iter()
.find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
}
fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
let s = usize::try_from(start).ok()?.checked_sub(1)?;
let lines: Vec<&str> = content.lines().collect();
if s >= lines.len() {
return None;
}
let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
Some(lines[s..e].join("\n"))
}
fn signature_in(content: &str, line: i64) -> Option<String> {
let idx = usize::try_from(line).ok()?.checked_sub(1)?;
let l = content.lines().nth(idx)?.trim();
(!l.is_empty()).then(|| l.to_string())
}
#[derive(serde::Serialize)]
struct SymbolOut {
name: String,
kind: String,
language: String,
file: String,
line: i64,
#[serde(skip_serializing_if = "Option::is_none")]
end_line: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
visibility: Option<String>,
repo: String,
#[serde(skip_serializing_if = "Option::is_none")]
signature: Option<String>,
}
fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
let mut store = match open_store() {
Ok(s) => s,
Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
};
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
let rel = repo_relative(&root, &cwd, file_arg);
let identity = resolve_identity(&store, &root);
let coverage = store.coverage_status(&identity).ok().flatten();
let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
let current = store.repository_id(&identity).ok().flatten();
let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
let needs_warm = warming_ok
&& (coverage.as_deref() != Some("complete")
|| worktree_changed(&root, indexed_head.as_deref()));
if needs_warm {
let budget = answer_warm_budget() + deferred_warm_budget();
let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
}
let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
return emit_symbols(out, &[]); };
let mut rows = match store.symbols_in_file(repo_id, &rel) {
Ok(r) => r,
Err(e) => return fail(format_args!("rq: {e}")),
};
if !kinds.is_empty() {
rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
}
if !langs.is_empty() {
rows.retain(|r| langs.iter().any(|l| l == &r.language));
}
let content = hit_file_roots(&store, &identity, Some(&root))
.iter()
.find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
let syms: Vec<SymbolOut> = rows
.into_iter()
.map(|r| SymbolOut {
signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
name: r.name,
kind: r.kind,
language: r.language,
file: r.file,
line: r.line,
end_line: r.end_line,
parent: r.parent,
visibility: r.visibility,
repo: r.repo_identity,
})
.collect();
emit_symbols(out, &syms)
}
fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
if syms.is_empty() {
match out {
Output::Json | Output::Ndjson => {
let obj = serde_json::json!({ "status": "no_match" });
let _ = emit_json(out, &obj); }
Output::Text => eprintln!("no symbols"),
}
return ExitCode::FAILURE;
}
if let Some(code) = emit_rows(out, syms) {
return code;
}
match out {
Output::Json | Output::Ndjson => {}
Output::Text => {
for s in syms {
let qualified = match &s.parent {
Some(p) => format!("{} · {p}", s.name),
None => s.name.clone(),
};
println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
if let Some(sig) = &s.signature {
println!(" {sig}");
}
}
}
}
ExitCode::SUCCESS
}
fn keyword_kind(token: &str) -> Option<&'static str> {
match token.to_ascii_lowercase().as_str() {
"class" => Some("class"),
"module" => Some("module"),
"method" => Some("method"),
"function" | "fn" => Some("function"),
"struct" | "type" => Some("struct"),
"enum" => Some("enum"),
"trait" | "interface" => Some("trait"),
_ => None,
}
}
fn split_kind_keyword(
target: String,
dirs: Vec<String>,
) -> (Option<&'static str>, String, Vec<String>) {
if let Some((head, rest)) = target.split_once(char::is_whitespace) {
let rest = rest.trim();
if let Some(k) = keyword_kind(head)
&& !rest.is_empty()
{
return (Some(k), rest.to_string(), dirs);
}
} else if let Some(k) = keyword_kind(&target)
&& let Some((query, extra)) = dirs.split_first()
{
return (Some(k), query.clone(), extra.to_vec());
}
(None, target, dirs)
}
fn canonical_kind(s: &str) -> String {
match s.to_ascii_lowercase().as_str() {
"c" | "class" => "class",
"m" | "method" => "method",
"f" | "fn" | "func" | "function" => "function",
"mod" | "module" => "module",
"s" | "struct" | "type" => "struct",
"e" | "enum" => "enum",
"t" | "trait" | "interface" => "trait",
other => return other.to_string(),
}
.to_string()
}
fn canonical_langs(s: &str) -> Vec<String> {
let t = s.to_ascii_lowercase();
let alias = match t.as_str() {
"rb" => Some("ruby"),
"rs" => Some("rust"),
"golang" => Some("go"),
"ts" | "tsx" => Some("typescript"),
"js" | "jsx" => Some("javascript"),
_ => None,
};
let matched: Vec<String> = crate::lang::languages()
.into_iter()
.filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
.map(str::to_string)
.collect();
if matched.is_empty() { vec![t] } else { matched }
}
fn match_color() -> Option<String> {
if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
return None;
}
let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
gc.split(':').find_map(|e| {
e.strip_prefix("mt=")
.or_else(|| e.strip_prefix("ms="))
.filter(|v| !v.is_empty())
.map(str::to_string)
})
});
Some(style.unwrap_or_else(|| "1;31".to_string()))
}
fn hl(text: &str, query: &str, color: Option<&str>) -> String {
match color {
Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
None => text.to_string(),
}
}
fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
let Some(c) = color else {
return path.to_string();
};
let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
let base_start = path[..base_byte].chars().count();
let stem = crate::search::path_stem(path);
let positions: Vec<usize> = crate::search::match_positions(query, stem)
.into_iter()
.map(|p| p + base_start)
.collect();
highlight(path, &positions, c)
}
fn highlight(text: &str, positions: &[usize], color: &str) -> String {
if positions.is_empty() {
return text.to_string();
}
let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
let mut out = String::new();
let mut on = false;
for (i, c) in text.chars().enumerate() {
match (matched.contains(&i), on) {
(true, false) => {
out.push_str("\x1b[");
out.push_str(color);
out.push('m');
on = true;
}
(false, true) => {
out.push_str("\x1b[0m");
on = false;
}
_ => {}
}
out.push(c);
}
if on {
out.push_str("\x1b[0m");
}
out
}
fn under_any(file: &str, paths: &[String]) -> bool {
paths.iter().any(|p| {
let p = p.trim_start_matches("./").trim_end_matches('/');
p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
})
}
fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
let p = std::path::Path::new(file);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
};
let abs = abs.canonicalize().unwrap_or(abs);
abs.strip_prefix(root)
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_else(|_| file.to_string())
}
fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
use std::collections::HashSet;
let mut seen = HashSet::new();
let mut changed = false;
for hit in hits {
if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
continue;
}
let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
continue;
};
let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
continue;
};
if let Ok(crate::index::Refresh::Updated) =
crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
{
changed = true;
}
}
changed
}
fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
if let Ok(canon) = cwd.canonicalize() {
if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
return identity;
}
if crate::index::repo_root(cwd).is_none() {
return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
}
}
crate::index::detect_identity(cwd).to_string()
}
fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
let explicit = path.is_some();
let target = path.unwrap_or_else(|| PathBuf::from("."));
let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
let mut subdirs = subdirs.to_vec();
if explicit
&& let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
&& t != r
&& let Ok(rel) = t.strip_prefix(&r)
&& !rel.as_os_str().is_empty()
{
subdirs.push(rel.to_string_lossy().into_owned());
}
let mut store = match open_store() {
Ok(s) => s,
Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
};
let identity = crate::index::detect_identity(&root).to_string();
match crate::index::index_under(&mut store, &root, &subdirs) {
Ok(stats) => {
let subtree = !subdirs.is_empty();
let totals = store
.repository_id(&identity)
.ok()
.flatten()
.and_then(|id| store.repo_totals(id).ok());
match out {
Output::Json | Output::Ndjson => {
let (files, symbols) = match totals {
Some((f, s)) => (Some(f), Some(s)),
None => (None, None),
};
return emit_json(
out,
&serde_json::json!({
"repo": identity,
"scope": if subtree { "subtree" } else { "full" },
"files_added": stats.files_indexed,
"symbols_added": stats.symbols,
"files": files,
"symbols": symbols,
}),
);
}
Output::Text => {
let scope = if subtree { " (subtree seed)" } else { "" };
match totals {
Some((files, symbols)) => println!(
"{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
stats.files_indexed, stats.symbols
),
None => println!(
"{} file(s)/{} symbol(s) added this run{scope}",
stats.files_indexed, stats.symbols
),
}
}
}
ExitCode::SUCCESS
}
Err(e) => fail(format_args!("rq --index: {e}")),
}
}
fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
let mut store = match open_store() {
Ok(s) => s,
Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
};
let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
let root = crate::index::repo_root(&path).unwrap_or(path);
let from_path = crate::index::detect_identity(&root).to_string();
let resolved = match store.repository_id(&from_path) {
Ok(Some(id)) => Some((from_path.clone(), id)),
Ok(None) => target.as_deref().and_then(|s| {
store
.repository_id(s)
.ok()
.flatten()
.map(|id| (s.to_string(), id))
}),
Err(e) => return fail(format_args!("rq --drop: {e}")),
};
let Some((identity, repo_id)) = resolved else {
return match out {
Output::Text => {
println!("not indexed: {from_path}");
ExitCode::SUCCESS
}
_ => emit_json(
out,
&serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
),
};
};
let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
match store.drop_repository(repo_id) {
Ok(()) => match out {
Output::Text => {
println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
ExitCode::SUCCESS
}
_ => emit_json(
out,
&serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
),
},
Err(e) => fail(format_args!("rq --drop: {e}")),
}
}
fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
let rendered = if out == Output::Json {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
};
match rendered {
Ok(s) => {
println!("{s}");
ExitCode::SUCCESS
}
Err(e) => fail(format_args!("rq: {e}")),
}
}
fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
match out {
Output::Json => match serde_json::to_string_pretty(rows) {
Ok(s) => println!("{s}"),
Err(e) => return Some(fail(format_args!("rq: {e}"))),
},
Output::Ndjson => {
for r in rows {
match serde_json::to_string(r) {
Ok(line) => println!("{line}"),
Err(e) => return Some(fail(format_args!("rq: {e}"))),
}
}
}
Output::Text => {}
}
None
}
fn cmd_status(out: Output) -> ExitCode {
let store = match open_store() {
Ok(s) => s,
Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
};
let rows = match store.coverage_overview() {
Ok(rows) => rows,
Err(e) => return fail(format_args!("rq --status: {e}")),
};
if let Some(code) = emit_rows(out, &rows) {
return code;
}
match out {
Output::Json | Output::Ndjson => {}
Output::Text if rows.is_empty() => {
println!("no repositories indexed yet (try `rq --index`)");
}
Output::Text => {
for r in &rows {
println!(
"{:<10} {:>6} files {:>7} symbols {}",
r.status, r.files, r.symbols, r.identity
);
}
}
}
ExitCode::SUCCESS
}
fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
let path = db_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(Store::open(&path)?)
}
fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
if let Ok(p) = std::env::var("RQ_DB") {
return Ok(PathBuf::from(p));
}
let home = std::env::var("HOME")?;
Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
}
fn fail(args: std::fmt::Arguments) -> ExitCode {
eprintln!("{args}");
ExitCode::FAILURE
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_menu_choice_parsing() {
assert_eq!(parse_choice("\n", 5), Some(0));
assert_eq!(parse_choice(" ", 5), Some(0));
assert_eq!(parse_choice("3", 5), Some(2));
assert_eq!(parse_choice("5", 5), Some(4));
assert_eq!(parse_choice("6", 5), None);
assert_eq!(parse_choice("0", 5), None);
assert_eq!(parse_choice("q", 5), None);
}
#[test]
fn wait_duration_parsing() {
use std::time::Duration;
assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
assert!(parse_wait("0s").unwrap().is_zero());
assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
assert!(parse_wait("2x").is_err());
assert!(parse_wait("").is_err());
assert!(parse_wait("s").is_err());
assert!(parse_wait("-1s").is_err());
}
#[test]
fn leading_kind_keyword_becomes_a_kind_filter() {
let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
assert_eq!(
split_kind_keyword("class".into(), d(&["Widget"])),
(Some("class"), "Widget".into(), vec![])
);
assert_eq!(
split_kind_keyword("method zoom".into(), vec![]),
(Some("method"), "zoom".into(), vec![])
);
assert_eq!(
split_kind_keyword("fn".into(), d(&["Foo::run"])),
(Some("function"), "Foo::run".into(), vec![])
);
assert_eq!(
split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
(Some("struct"), "Gadget".into(), d(&["src"]))
);
}
#[test]
fn a_bare_or_non_keyword_query_is_left_alone() {
let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
assert_eq!(
split_kind_keyword("class".into(), vec![]),
(None, "class".into(), vec![])
);
assert_eq!(
split_kind_keyword("Widget".into(), d(&["app"])),
(None, "Widget".into(), d(&["app"]))
);
assert_eq!(
split_kind_keyword("c".into(), d(&["Foo"])),
(None, "c".into(), d(&["Foo"]))
);
}
#[test]
fn a_language_selects_by_prefix_or_alias() {
assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
assert_eq!(canonical_langs("t"), ["typescript"]);
assert_eq!(canonical_langs("ts"), ["typescript"]);
assert_eq!(canonical_langs("jsx"), ["javascript"]);
assert_eq!(canonical_langs("rb"), ["ruby"]);
assert_eq!(canonical_langs("COBOL"), ["cobol"]);
}
#[test]
fn a_kind_normalizes_language_specific_spellings() {
assert_eq!(canonical_kind("f"), "function");
assert_eq!(canonical_kind("interface"), "trait");
assert_eq!(canonical_kind("type"), "struct");
let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
assert_eq!(
split_kind_keyword("interface".into(), d(&["Renderer"])),
(Some("trait"), "Renderer".into(), vec![])
);
}
#[test]
fn highlight_wraps_matched_runs() {
assert_eq!(
highlight("FooThing", &[0, 1, 2], "1;31"),
"\u{1b}[1;31mFoo\u{1b}[0mThing"
);
assert_eq!(
highlight("FooThing", &[0, 3], "1"),
"\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
);
assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
}
#[test]
fn progress_ui_only_for_an_interactive_text_terminal() {
assert!(show_progress(Output::Text, true));
assert!(!show_progress(Output::Json, true));
assert!(!show_progress(Output::Ndjson, true));
assert!(!show_progress(Output::Text, false));
}
#[test]
fn repo_label_uses_the_directory_name() {
assert_eq!(
repo_label(Some(std::path::Path::new("/src/widgets"))),
"widgets"
);
assert_eq!(repo_label(None), "repo");
}
#[test]
fn hl_path_highlights_the_stem_not_the_extension() {
let out = hl_path(
"app/employees_controller.rb",
"employeescontroller",
Some("1;31"),
);
assert!(
out.starts_with("app/\u{1b}[1;31memployees"),
"stem highlighted: {out:?}"
);
assert!(
out.ends_with("controller\u{1b}[0m.rb"),
"`.rb` left un-highlighted: {out:?}"
);
}
}