use std::collections::HashSet;
use std::io::IsTerminal;
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 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 --symbols FILE outline a file's definitions, in line order\n \
rq thing -x rust restrict to a language (ruby/rust/go/python)\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."
)]
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(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
open: 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, 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, value_name = "SHELL")]
completions: Option<Shell>,
#[arg(short = 'v', long)]
verbose: 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::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 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 {
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 paths = cli.path.clone();
paths.extend(cli.dirs.clone());
let 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);
}
match cli.target {
Some(query) => cmd_search(
&query,
cli.explain,
out,
&paths,
&kinds,
&langs,
cli.limit,
cli.no_record,
cli.open,
),
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(15);
#[allow(clippy::too_many_arguments)]
fn cmd_search(
query: &str,
explain: bool,
out: Output,
paths: &[String],
kinds: &[String],
langs: &[String],
want: usize,
no_record: bool,
open: bool,
) -> ExitCode {
let limit = if paths.is_empty() && kinds.is_empty() && langs.is_empty() {
want
} else {
(want * 20).max(PATH_HEADROOM)
};
let _timer = crate::trace::Timer::start("search done");
let t_setup = std::time::Instant::now();
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().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()));
let active_paths: Vec<String> = match &root {
Some(c) if cwd_is_git => crate::index::branch_changed_files(c),
_ => Vec::new(),
};
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();
let known = coverage.is_some();
let warming_ok = (cwd_is_git || known) && coverage.as_deref() != Some("partial");
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 current = identity
.as_deref()
.and_then(|id| store.repository_id(id).ok().flatten());
let active = crate::search::ActiveFiles::new(active_paths.clone());
if !no_record && let Some(repo) = current {
let qn = query.to_ascii_lowercase();
if store.is_repeat_search(repo, &qn).unwrap_or(false) {
let _ = store.decay_selections(repo, &qn);
}
}
let warm_budget = answer_warm_budget() + deferred_warm_budget();
let was_warming = coverage.as_deref() != Some("complete");
let want_warm = warming_ok
&& match &root {
Some(c) => {
was_warming || !repo_unchanged_since_index(&store, c, current, coverage.as_deref())
}
None => false,
};
let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let indexer = (want_warm && root.is_some()).then(|| {
crate::trace!(
"background warm ({warm_budget:?}, {} 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 _ =
crate::index::index_budgeted(&mut idx, &root, &active, warm_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 answer_deadline = std::time::Instant::now() + answer_warm_budget();
let polling = indexer.is_some() && was_warming;
let mut hits = loop {
match crate::search::search(&store, query, current, &active, limit) {
Ok(h) => {
let confident = h.first().is_some_and(|hit| {
hit.features
.iter()
.any(|f| matches!(f.name, "exact" | "prefix"))
});
if !polling
|| confident
|| warm_done.load(std::sync::atomic::Ordering::Relaxed)
|| std::time::Instant::now() >= answer_deadline
{
break h;
}
}
Err(e) => {
if let Some(h) = indexer {
let _ = h.join();
}
return fail(format_args!("rq: {e}"));
}
}
std::thread::sleep(POLL_INTERVAL);
};
if !hits.is_empty() && revalidate_top(&mut store, &hits) {
hits = crate::search::search(&store, query, current, &active, limit).unwrap_or_default();
}
if hits.is_empty()
&& indexer.is_none()
&& coverage.is_none()
&& let Some(root) = &root
{
crate::trace!("empty → live (in-memory) scan of an untracked dir");
let deadline = std::time::Instant::now() + live_fallback_budget();
let mut h =
crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
if h.is_empty() {
h = crate::search::live_search(
root,
query,
limit,
&HashSet::new(),
Some(deadline),
false,
);
}
hits = h;
}
let strong = |h: &crate::search::Hit| {
h.features
.iter()
.any(|f| matches!(f.name, "exact" | "prefix"))
};
if hits.iter().any(strong) {
hits.retain(strong);
}
if !paths.is_empty() {
let here = cwd.clone().unwrap_or_else(|| PathBuf::from("."));
let base = root.clone().unwrap_or_else(|| here.clone());
let norm: Vec<String> = paths
.iter()
.map(|p| repo_relative(&base, &here, p))
.collect();
hits.retain(|h| under_any(&h.file, &norm));
}
if !kinds.is_empty() {
hits.retain(|h| kinds.iter().any(|k| k == &h.kind));
}
if !langs.is_empty() {
hits.retain(|h| langs.iter().any(|l| l == &h.language));
}
if !paths.is_empty() || !kinds.is_empty() || !langs.is_empty() {
hits.truncate(want);
}
if hits.is_empty() {
match out {
Output::Json => println!("[]"),
Output::Ndjson => {}
Output::Text => eprintln!("no matches for {query:?}"),
}
if let Some(h) = indexer {
let _ = h.join();
}
return ExitCode::FAILURE;
}
for hit in &mut hits {
hit.signature = read_signature(
&store,
&hit.repo_identity,
&hit.file,
hit.line,
cwd.as_deref(),
);
}
if open {
return finish_open(
&mut store,
&hits,
query,
current,
root.as_deref(),
no_record,
);
}
match out {
Output::Ndjson => {
for hit in &hits {
match serde_json::to_string(hit) {
Ok(line) => println!("{line}"),
Err(e) => return fail(format_args!("rq: {e}")),
}
}
}
Output::Json => match serde_json::to_string_pretty(&hits) {
Ok(s) => println!("{s}"),
Err(e) => return fail(format_args!("rq: {e}")),
},
Output::Text => {
let color = match_color();
let c = color.as_deref();
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 explain {
let parts: Vec<String> = hit
.features
.iter()
.map(|f| format!("{} {:.0}", f.name, f.value))
.collect();
println!(" score {:.0} = {}", hit.score, parts.join(" + "));
}
}
}
}
if !no_record {
let _ = store.record_event(
"search",
Some(&query.to_ascii_lowercase()),
current,
None,
None,
None,
);
}
deferred_maintenance(&mut store);
if let Some(h) = indexer {
let _ = h.join();
}
ExitCode::SUCCESS
}
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 repo_unchanged_since_index(
store: &Store,
cwd: &std::path::Path,
current: Option<i64>,
coverage: Option<&str>,
) -> bool {
if coverage != Some("complete") {
return false;
}
let Some(id) = current else { return false };
let indexed_head = store.indexed_head(id).ok().flatten();
indexed_head.is_some()
&& crate::index::git_head(cwd) == indexed_head
&& !crate::index::is_dirty(cwd)
}
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 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 read_signature(
store: &Store,
repo_identity: &str,
file: &str,
line: i64,
cwd: Option<&std::path::Path>,
) -> Option<String> {
let root = store
.repository_id(repo_identity)
.ok()
.flatten()
.and_then(|id| store.checkout_root(id).ok().flatten())
.map(PathBuf::from)
.or_else(|| cwd.map(std::path::Path::to_path_buf))?;
let content = std::fs::read_to_string(root.join(file)).ok()?;
signature_in(&content, line)
}
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")]
parent: 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())
&& coverage.as_deref() != Some("partial");
let current = store.repository_id(&identity).ok().flatten();
let needs_warm = warming_ok
&& (coverage.as_deref() != Some("complete")
|| !repo_unchanged_since_index(&store, &root, current, coverage.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 file_root = store
.checkout_root(repo_id)
.ok()
.flatten()
.map(PathBuf::from)
.unwrap_or_else(|| root.clone());
let content = std::fs::read_to_string(file_root.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,
parent: r.parent,
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 => println!("[]"),
Output::Ndjson => {}
Output::Text => eprintln!("no symbols"),
}
return ExitCode::FAILURE;
}
match out {
Output::Ndjson => {
for s in syms {
match serde_json::to_string(s) {
Ok(line) => println!("{line}"),
Err(e) => return fail(format_args!("rq: {e}")),
}
}
}
Output::Json => match serde_json::to_string_pretty(&syms) {
Ok(s) => println!("{s}"),
Err(e) => return fail(format_args!("rq: {e}")),
},
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 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" => "struct",
"e" | "enum" => "enum",
"t" | "trait" => "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"),
_ => 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 base = &path[base_byte..];
let stem = match base.rfind('.') {
Some(i) if i > 0 => &base[..i],
_ => base,
};
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 partial = !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 partial { "partial" } else { "full" },
"files_added": stats.files_indexed,
"symbols_added": stats.symbols,
"files": files,
"symbols": symbols,
}),
);
}
Output::Text => {
let scope = if partial { " (partial)" } 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 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}")),
};
match out {
Output::Json => match serde_json::to_string_pretty(&rows) {
Ok(s) => println!("{s}"),
Err(e) => return fail(format_args!("rq: {e}")),
},
Output::Ndjson => {
for r in &rows {
match serde_json::to_string(r) {
Ok(line) => println!("{line}"),
Err(e) => return fail(format_args!("rq: {e}")),
}
}
}
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 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 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:?}"
);
}
}