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)\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, 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 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
};
cmd_search(&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,
})
}
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,
show: bool,
}
fn cmd_search(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 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;
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 only_repo = if all_repos { None } else { current };
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 = if warm_detach_enabled() {
answer_warm_budget()
} else {
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 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())
};
let polling = indexer.is_some() && was_warming;
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);
};
if drew_progress {
clear_progress();
}
let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
if !hits.is_empty() && revalidate_top(&mut 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 incomplete = (block || no_wait)
&& identity
.as_deref()
.and_then(|id| store.coverage_status(id).ok().flatten())
.as_deref()
!= Some("complete");
maybe_detach_warm(&store, want_warm, root.as_deref(), 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(
&mut store,
&hits,
query,
current,
root.as_deref(),
no_record,
);
}
if let Some(code) = render_hits(args, &hits) {
return code;
}
if !no_record {
let _ = store.record_event(
"search",
Some(&query.to_ascii_lowercase()),
current,
None,
None,
None,
);
}
deferred_maintenance(&mut store);
if block {
INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Some(h) = indexer {
let _ = h.join();
}
maybe_detach_warm(&store, want_warm, root.as_deref(), identity.as_deref());
ExitCode::SUCCESS
}
fn maybe_detach_warm(
store: &Store,
want_warm: 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 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!(
"detached warm: 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> {
if let Some(code) = emit_rows(args.out, hits) {
return Some(code);
}
if args.out != Output::Text {
return None;
}
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 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 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 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 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" => Some("struct"),
"enum" => Some("enum"),
"trait" => 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" => "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 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 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:?}"
);
}
}