1use std::collections::HashSet;
4use std::io::{IsTerminal, Write};
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::time::Duration;
8
9use clap::{CommandFactory, Parser};
10use clap_complete::Shell;
11
12use crate::store::Store;
13
14#[derive(Parser)]
18#[command(
19 name = "rq",
20 version,
21 about = "Ranked definition lookup — the one place a symbol is defined, first.",
22 long_about = "rq finds where a symbol is defined and ranks the one you most \
23likely meant to the top — not every match.\n\n\
24Search is the default action; operations are flags, not subcommands, so every \
25word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
26your current repo and recently-active files, and learns from the results you open \
27(see RECORDING below). Run `rq <query> --explain` to see the score behind each result.",
28 after_help = "EXAMPLES:\n \
29rq thing search for a definition named or like \"thing\"\n \
30rq wibble --explain same, plus the score behind each result\n \
31rq thing --json machine-readable results (for editors/agents)\n \
32rq thing --no-record search without recording it (speculative/agent queries)\n \
33rq thing --no-wait answer now from the committed index; don't block on a rebuild\n \
34rq thing --wait 2s ...or wait up to a bounded time for the index to warm\n \
35rq thing app/web restrict to a directory (rg-style)\n \
36rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
37rq class Widget a leading kind keyword is shorthand for -k\n \
38rq --symbols FILE outline a file's definitions, in line order\n \
39rq thing -x rust restrict to a language (ruby/rust/go/python/ts/js)\n \
40rq -o thing open the best match in your editor (and record it)\n \
41rq --index index the current repository\n \
42rq --status show indexing coverage\n \
43rq --drop remove this repo's index (opposite of --index)\n\n\
44SHORT FLAGS (easy to misread):\n \
45-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
46RECORDING (editor/shell hook):\n \
47rq --record --file <path> --line <n> <query>\n \
48Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
49to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
50The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
51automatically on the first search in a git repo. On a large, cold repo a search \
52keeps indexing until it can answer rather than reporting a premature \"no \
53matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
54= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
55)]
56struct Cli {
57 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
64 target: Option<String>,
65
66 #[arg(value_name = "PATH")]
68 dirs: Vec<String>,
69
70 #[arg(short = 'e', long)]
72 explain: bool,
73
74 #[arg(long)]
77 no_record: bool,
78
79 #[arg(long = "no-wait")]
85 no_wait: bool,
86
87 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
92 wait: Option<Duration>,
93
94 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
99 open: bool,
100
101 #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
105 show: bool,
106
107 #[arg(short = 'j', long)]
109 json: bool,
110
111 #[arg(short = 'J', long, conflicts_with = "json")]
113 ndjson: bool,
114
115 #[arg(short = 'p', long, value_name = "DIR")]
117 path: Vec<String>,
118
119 #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
121 limit: usize,
122
123 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
127 kind: Vec<String>,
128
129 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
133 lang: Vec<String>,
134
135 #[arg(long = "all-repos")]
138 all_repos: bool,
139
140 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
142 index: Option<Option<String>>,
143
144 #[arg(long, conflicts_with_all = ["index", "record"])]
146 status: bool,
147
148 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
151 symbols: Option<String>,
152
153 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
157 drop: bool,
158
159 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
162 record: bool,
163
164 #[arg(long)]
166 file: Option<String>,
167
168 #[arg(long)]
170 line: Option<i64>,
171
172 #[arg(long, default_value = "select")]
174 event: String,
175
176 #[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"])]
180 warm: Option<Option<String>>,
181
182 #[arg(long, value_name = "SHELL")]
184 completions: Option<Shell>,
185
186 #[arg(short = 'v', long)]
189 verbose: bool,
190
191 #[arg(long)]
195 profile: bool,
196
197 #[arg(long, value_name = "N", default_value_t = 0)]
200 jobs: usize,
201}
202
203pub fn run() -> ExitCode {
205 let cli = Cli::parse();
206 crate::trace::enable_from(cli.verbose);
207 crate::profile::enable_from(cli.profile);
208 crate::index::set_parse_jobs(cli.jobs);
209
210 if let Some(shell) = cli.completions {
211 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
212 return ExitCode::SUCCESS;
213 }
214 if let Some(path) = &cli.index {
215 let out = output_format(&cli);
217 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
218 }
219 if let Some(path) = &cli.warm {
220 return cmd_warm(path.as_deref());
221 }
222 if cli.status {
223 return cmd_status(output_format(&cli));
224 }
225 if cli.drop {
226 let out = output_format(&cli);
227 return cmd_drop(cli.target, out);
228 }
229 if cli.record {
230 if !matches!(cli.event.as_str(), "select" | "open") {
232 return fail(format_args!(
233 "rq --record: unknown --event {:?} (expected select or open)",
234 cli.event
235 ));
236 }
237 let file = cli.file.expect("--record requires --file");
239 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
240 }
241 let out = output_format(&cli);
242 let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
243 let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
245 if let Some(file) = &cli.symbols {
246 return cmd_symbols(file, &kinds, &langs, out);
247 }
248 let mut paths = cli.path.clone();
250 match cli.target {
251 Some(target) => {
252 let query = if cli.kind.is_empty() {
255 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
256 if let Some(k) = kw {
257 kinds.push(k.to_string());
258 }
259 paths.extend(dirs);
260 query
261 } else {
262 paths.extend(cli.dirs.clone());
263 target
264 };
265 let mut session = match Session::open() {
266 Ok(s) => s,
267 Err(code) => return code,
268 };
269 cmd_search(
270 &mut session,
271 &SearchArgs {
272 query: &query,
273 explain: cli.explain,
274 out,
275 paths: &paths,
276 kinds: &kinds,
277 langs: &langs,
278 want: cli.limit,
279 no_record: cli.no_record,
280 no_wait: cli.no_wait,
281 wait: cli.wait,
282 open: cli.open,
283 all_repos: cli.all_repos,
284 show: cli.show,
285 batch: false,
286 },
287 )
288 }
289 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
292 None => {
294 let _ = Cli::command().print_long_help();
295 ExitCode::SUCCESS
296 }
297 }
298}
299
300#[derive(Clone, Copy, PartialEq)]
302enum Output {
303 Text,
304 Json,
305 Ndjson,
306}
307
308fn output_format(cli: &Cli) -> Output {
309 if cli.ndjson {
310 Output::Ndjson
311 } else if cli.json {
312 Output::Json
313 } else {
314 Output::Text
315 }
316}
317
318const PATH_HEADROOM: usize = 200;
321
322const POLL_INTERVAL: Duration = Duration::from_millis(100);
329
330const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
334
335const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
339
340struct SearchArgs<'a> {
342 query: &'a str,
343 explain: bool,
344 out: Output,
345 paths: &'a [String],
346 kinds: &'a [String],
347 langs: &'a [String],
348 want: usize,
350 no_record: bool,
351 no_wait: bool,
353 wait: Option<Duration>,
356 open: bool,
357 all_repos: bool,
358 batch: bool,
362 show: bool,
363}
364
365struct Session {
376 store: Store,
377 cwd: Option<PathBuf>,
378 cwd_is_git: bool,
379 root: Option<PathBuf>,
380 active_paths: Vec<String>,
381 branch_refresh: Option<BranchRefresh>,
382 identity: Option<String>,
383 coverage: Option<String>,
384}
385
386impl Session {
387 fn open() -> std::result::Result<Session, ExitCode> {
389 let open_span = crate::profile::span("store open");
390 let store = match open_store() {
391 Ok(s) => s,
392 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
393 };
394 drop(open_span);
395 let git_span = crate::profile::span("setup: git root");
396 let cwd = std::env::current_dir().ok();
397 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
398
399 let root = cwd
405 .as_deref()
406 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
407 drop(git_span);
408
409 let mut branch_span = crate::profile::span("setup: branch files");
412 let (active_paths, branch_refresh) = match &root {
413 Some(c) if cwd_is_git => cached_branch_files(&store, c),
414 _ => (Vec::new(), None),
415 };
416 branch_span.note(|| {
417 let how = if branch_refresh.is_some() {
418 "cached, refreshing alongside"
419 } else {
420 "cached"
421 };
422 format!("{} changed, {how}", active_paths.len())
423 });
424 drop(branch_span);
425
426 let mut identity_span = crate::profile::span("setup: identity");
431 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
432 let coverage = identity
433 .as_deref()
434 .and_then(|id| store.coverage_status(id).ok())
435 .flatten();
436 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
437 drop(identity_span);
438 Ok(Session {
439 store,
440 cwd,
441 cwd_is_git,
442 root,
443 active_paths,
444 branch_refresh,
445 identity,
446 coverage,
447 })
448 }
449}
450
451fn cmd_batch(
466 cli: &Cli,
467 out: Output,
468 paths: &[String],
469 kinds: &[String],
470 langs: &[String],
471) -> ExitCode {
472 if out == Output::Json {
473 return fail(format_args!(
474 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
475 where each line carries the query it answers"
476 ));
477 }
478 if cli.open || cli.show {
479 return fail(format_args!(
480 "rq: --open and --show act on a single result, not a stream of queries"
481 ));
482 }
483
484 use std::io::BufRead;
485 let queries: Vec<String> = std::io::stdin()
486 .lock()
487 .lines()
488 .map_while(std::result::Result::ok)
489 .map(|l| l.trim().to_string())
490 .filter(|l| !l.is_empty())
491 .collect();
492 if queries.is_empty() {
496 let _ = Cli::command().print_long_help();
497 return ExitCode::SUCCESS;
498 }
499
500 let mut session = match Session::open() {
501 Ok(s) => s,
502 Err(code) => return code,
503 };
504
505 if !cli.no_wait
508 && session.coverage.as_deref() != Some("complete")
509 && let Some(root) = session.root.clone()
510 {
511 {
512 let budget = cli.wait.unwrap_or_else(wait_budget);
513 crate::trace!(
514 "batch: warming {} queries' worth of index first",
515 queries.len()
516 );
517 let active = session.active_paths.clone();
518 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
519 session.coverage = session
520 .identity
521 .as_deref()
522 .and_then(|id| session.store.coverage_status(id).ok())
523 .flatten();
524 }
525 }
526
527 let mut worst = ExitCode::SUCCESS;
528 let mut any_hit = false;
529 for query in &queries {
530 let code = cmd_search(
531 &mut session,
532 &SearchArgs {
533 query,
534 explain: cli.explain,
535 out,
536 paths,
537 kinds,
538 langs,
539 want: cli.limit,
540 no_record: cli.no_record,
541 no_wait: true,
545 wait: cli.wait,
546 open: false,
547 all_repos: cli.all_repos,
548 show: false,
549 batch: true,
550 },
551 );
552 if code == ExitCode::SUCCESS {
553 any_hit = true;
554 } else {
555 worst = code;
556 }
557 }
558 if any_hit { ExitCode::SUCCESS } else { worst }
561}
562
563fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
564 let &SearchArgs {
565 query,
566 out,
567 want,
568 no_record,
569 no_wait,
570 wait,
571 open,
572 all_repos,
573 show,
574 ..
575 } = args;
576 let wait_budget = wait.unwrap_or_else(wait_budget);
579 let no_wait = no_wait || wait_budget.is_zero();
580 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
583 want
584 } else {
585 (want * 20).max(PATH_HEADROOM)
586 };
587 let _timer = crate::trace::Timer::start("search done");
588 let profile_started = std::time::Instant::now();
589 let t_setup = std::time::Instant::now();
590 let setup_span = crate::profile::span("setup");
592 let Session {
595 store,
596 cwd,
597 cwd_is_git,
598 root,
599 active_paths,
600 branch_refresh,
601 identity,
602 coverage,
603 } = session;
604 let cwd_is_git = *cwd_is_git;
605
606 let known = coverage.is_some();
614 let warming_ok = cwd_is_git || known;
615 if crate::trace::enabled() {
616 crate::trace!(
617 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
618 root.as_deref().map_or("?".into(), crate::trace::abbrev),
619 identity.as_deref().unwrap_or("none"),
620 coverage.as_deref().unwrap_or("none"),
621 active_paths.len(),
622 );
623 }
624 let repo_span = crate::profile::span("setup: repo state");
625 let current = identity
626 .as_deref()
627 .and_then(|id| store.repository_id(id).ok().flatten());
628 let only_repo = if all_repos { None } else { current };
631 let active = crate::search::ActiveFiles::new(active_paths.clone());
632
633 drop(repo_span);
634 let warm_span = crate::profile::span("setup: warm decision");
635
636 let warm_budget = if warm_detach_enabled() {
643 answer_warm_budget()
644 } else {
645 answer_warm_budget() + deferred_warm_budget()
646 };
647 let was_warming = coverage.as_deref() != Some("complete");
648
649 let indexed_head = (!was_warming)
660 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
661 .flatten();
662 let staleness = (!was_warming && warming_ok && !args.batch)
665 .then(|| root.clone())
666 .flatten()
667 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
668 let want_warm = warming_ok && was_warming && root.is_some();
671
672 let block = want_warm && was_warming && !no_wait;
686 let progress_ui = block && show_progress(out, stderr_interactive());
691 let indexer_budget = if block { wait_budget } else { warm_budget };
692 if progress_ui {
693 install_interrupt_handler();
694 }
695
696 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
699 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
700 crate::trace!(
701 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
702 crate::index::parse_jobs()
703 );
704 let root = root.clone().expect("checked");
705 let active = active_paths.clone();
706 let q = query.to_string();
707 let warm_done = std::sync::Arc::clone(&warm_done);
708 std::thread::spawn(move || {
709 if let Ok(mut idx) = open_store() {
710 let _ = if block {
712 crate::index::index_budgeted_cancellable(
715 &mut idx,
716 &root,
717 &active,
718 indexer_budget,
719 Some(&q),
720 &INTERRUPTED,
721 )
722 } else {
723 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
724 };
725 }
726 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
727 })
728 });
729
730 crate::trace!(
737 "setup (open + repo detect + warm decision): {} ms",
738 t_setup.elapsed().as_millis()
739 );
740 let poll_start = std::time::Instant::now();
741 let deadline = if progress_ui {
745 None
746 } else if block {
747 Some(poll_start + wait_budget)
748 } else {
749 Some(poll_start + answer_warm_budget())
750 };
751 drop(warm_span);
752 let polling = indexer.is_some() && was_warming;
753 drop(setup_span);
757 let mut query_span = crate::profile::span("query");
758 let label = repo_label(root.as_deref());
759 let mut drew_progress = false;
760 let mut last_draw = poll_start;
761 let mut hits = loop {
762 match crate::search::search(store, query, current, only_repo, &active, limit) {
763 Ok(h) => {
764 let confident = h.first().is_some_and(|hit| {
765 hit.features
766 .iter()
767 .any(|f| matches!(f.name, "exact" | "prefix"))
768 });
769 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
770 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
771 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
772 if !polling || confident || warm_finished || stopped || timed_out {
773 break h;
774 }
775 if progress_ui
776 && poll_start.elapsed() >= HEADS_UP_DELAY
777 && last_draw.elapsed() >= PROGRESS_REDRAW
778 {
779 draw_progress(store, identity.as_deref(), &label);
780 drew_progress = true;
781 last_draw = std::time::Instant::now();
782 }
783 }
784 Err(e) => {
785 if let Some(h) = indexer {
786 let _ = h.join();
787 }
788 return fail(format_args!("rq: {e}"));
789 }
790 }
791 std::thread::sleep(POLL_INTERVAL);
792 };
793 query_span.note(|| {
794 if polling {
795 "polled a warming index".to_string()
796 } else {
797 String::new()
798 }
799 });
800 drop(query_span);
801 if drew_progress {
802 clear_progress();
803 }
804 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
806
807 if !hits.is_empty() && revalidate_top(store, &hits) {
809 hits = crate::search::search(store, query, current, only_repo, &active, limit)
810 .unwrap_or_default();
811 }
812
813 if !hits.iter().any(strong)
817 && indexer.is_none()
818 && coverage.is_none()
819 && let Some(root) = &root
820 {
821 let tail = live_fallback(root, query, limit);
822 hits = crate::search::merge(hits, tail, limit);
823 }
824
825 apply_gates(query, &mut hits);
826 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
827
828 if hits.is_empty() {
829 if block {
831 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
832 }
833 if let Some(h) = indexer {
834 let _ = h.join();
835 }
836 let mut incomplete = (block || no_wait)
843 && identity
844 .as_deref()
845 .and_then(|id| store.coverage_status(id).ok().flatten())
846 .as_deref()
847 != Some("complete");
848 incomplete |= settle_warm(
858 store,
859 staleness,
860 was_warming,
861 warming_ok,
862 root.as_deref(),
863 active_paths,
864 query,
865 warm_budget,
866 no_wait,
867 identity.as_deref(),
868 );
869 return no_match_code(out, query, interrupted, incomplete);
870 }
871
872 for hit in &mut hits {
875 hit.signature = read_signature(
876 store,
877 &hit.repo_identity,
878 &hit.file,
879 hit.line,
880 cwd.as_deref(),
881 );
882 }
883 attach_confidence(&mut hits);
884
885 if show && let Some(code) = show_top_definition(store, &mut hits, query, out, cwd.as_deref()) {
888 return code;
889 }
890
891 if open {
895 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
896 }
897
898 if let Some(code) = render_hits(args, &hits) {
899 return code;
900 }
901
902 if crate::profile::enabled() {
905 let total = profile_started.elapsed();
906 if args.out == Output::Text {
907 for line in crate::profile::report(total) {
908 eprintln!("{line}");
909 }
910 } else {
911 eprintln!("{}", crate::profile::json(total));
914 }
915 }
916
917 if let Some(refresh) = branch_refresh.take() {
924 refresh.store(store);
925 }
926
927 deferred_maintenance(store);
934
935 if block {
940 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
941 }
942 if let Some(h) = indexer {
943 let _ = h.join();
944 }
945 let _ = settle_warm(
946 store,
947 staleness,
948 was_warming,
949 warming_ok,
950 root.as_deref(),
951 active_paths,
952 query,
953 warm_budget,
954 no_wait,
955 identity.as_deref(),
956 );
957
958 ExitCode::SUCCESS
959}
960
961fn maybe_detach_warm(
964 store: &Store,
965 want_warm: bool,
966 changed: bool,
967 root: Option<&std::path::Path>,
968 identity: Option<&str>,
969) {
970 if !warm_detach_enabled() || !want_warm {
971 return;
972 }
973 let (Some(root), Some(id)) = (root, identity) else {
974 return;
975 };
976 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
980 return; }
982 spawn_detached_warm(root);
983}
984
985fn spawn_detached_warm(root: &std::path::Path) {
989 use std::os::unix::process::CommandExt;
990 let Ok(exe) = std::env::current_exe() else {
991 return;
992 };
993 let mut cmd = std::process::Command::new(exe);
994 cmd.arg("--warm")
995 .arg(root)
996 .stdin(std::process::Stdio::null())
997 .stdout(std::process::Stdio::null())
998 .stderr(std::process::Stdio::null())
999 .process_group(0);
1000 match cmd.spawn() {
1001 Ok(child) => crate::trace!(
1002 "background warm (detached): pid {} for {}",
1003 child.id(),
1004 crate::trace::abbrev(root)
1005 ),
1006 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1007 }
1008}
1009
1010const WARM_LOCK_TTL_SECS: i64 = 600;
1013
1014fn cmd_warm(path: Option<&str>) -> ExitCode {
1019 #[cfg(target_os = "macos")]
1022 unsafe extern "C" {
1023 fn setiopolicy_np(
1026 iotype: libc::c_int,
1027 scope: libc::c_int,
1028 policy: libc::c_int,
1029 ) -> libc::c_int;
1030 }
1031 unsafe {
1032 libc::nice(10);
1033 #[cfg(target_os = "macos")]
1034 setiopolicy_np(0, 0, 3);
1035 }
1036 let mut store = match open_store() {
1037 Ok(s) => s,
1038 Err(_) => return ExitCode::FAILURE,
1039 };
1040 let start = path
1041 .map(PathBuf::from)
1042 .or_else(|| std::env::current_dir().ok())
1043 .unwrap_or_else(|| PathBuf::from("."));
1044 let root = crate::index::repo_root(&start).unwrap_or(start);
1045 let identity = resolve_identity(&store, &root);
1046
1047 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1050 && pid != std::process::id()
1051 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1052 && now_secs() - ts < WARM_LOCK_TTL_SECS
1053 {
1054 return ExitCode::SUCCESS;
1055 }
1056 let _ = store.set_warm_lock(&identity, std::process::id());
1057
1058 let deadline = std::time::Instant::now() + warm_bg_budget();
1061 let active = crate::index::branch_changed_files(&root);
1062 loop {
1063 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1064 if remaining.is_zero() {
1065 break;
1066 }
1067 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1068 {
1069 Ok(s) => s,
1070 Err(_) => break,
1071 };
1072 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1073 || stats.files_indexed == 0
1074 {
1075 break;
1076 }
1077 }
1078 let _ = store.clear_warm_lock(&identity);
1079 ExitCode::SUCCESS
1080}
1081
1082fn now_secs() -> i64 {
1083 std::time::SystemTime::now()
1084 .duration_since(std::time::UNIX_EPOCH)
1085 .map(|d| d.as_secs() as i64)
1086 .unwrap_or(0)
1087}
1088
1089fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1092 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1093 let deadline = std::time::Instant::now() + live_fallback_budget();
1094 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1095 if !h.is_empty() {
1096 return h;
1097 }
1098 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1099}
1100
1101fn strong(h: &crate::search::Hit) -> bool {
1103 h.features
1104 .iter()
1105 .any(|f| matches!(f.name, "exact" | "prefix"))
1106}
1107
1108fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1117 if hits.iter().any(strong) {
1118 hits.retain(strong);
1119 }
1120 crate::search::apply_scope_gate(query, hits);
1121}
1122
1123fn apply_post_filters(
1126 args: &SearchArgs,
1127 cwd: Option<&std::path::Path>,
1128 root: Option<&std::path::Path>,
1129 hits: &mut Vec<crate::search::Hit>,
1130) {
1131 if !args.paths.is_empty() {
1132 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1136 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1137 let norm: Vec<String> = args
1138 .paths
1139 .iter()
1140 .map(|p| repo_relative(&base, &here, p))
1141 .collect();
1142 hits.retain(|h| under_any(&h.file, &norm));
1143 }
1144 if !args.kinds.is_empty() {
1145 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1146 }
1147 if !args.langs.is_empty() {
1148 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1149 }
1150 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1151 hits.truncate(args.want);
1152 }
1153}
1154
1155fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
1161 let status = if interrupted {
1162 "interrupted"
1163 } else if incomplete {
1164 "warming"
1165 } else {
1166 "no_match"
1167 };
1168 match out {
1169 Output::Json | Output::Ndjson => {
1170 let obj = serde_json::json!({ "status": status, "query": query });
1171 let _ = emit_json(out, &obj); }
1173 Output::Text if interrupted => {
1174 eprintln!("rq: indexing interrupted — run again to finish")
1175 }
1176 Output::Text if incomplete => eprintln!(
1177 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1178 ),
1179 Output::Text => eprintln!("no matches for {query:?}"),
1180 }
1181 if incomplete {
1182 ExitCode::from(2)
1183 } else {
1184 ExitCode::FAILURE
1185 }
1186}
1187
1188fn attach_confidence(hits: &mut [crate::search::Hit]) {
1192 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1193 if t.is_none_or(|t| h.score > t) {
1194 (Some(h.score), t)
1195 } else if s.is_none_or(|s| h.score > s) {
1196 (t, Some(h.score))
1197 } else {
1198 (t, s)
1199 }
1200 });
1201 for hit in hits.iter_mut() {
1202 let best_other = if Some(hit.score) == top { second } else { top };
1203 hit.confidence = crate::search::confidence(
1204 hit.score,
1205 crate::search::match_quality(&hit.features),
1206 best_other,
1207 );
1208 }
1209}
1210
1211fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1214 let render_span = crate::profile::span("render");
1218 if args.batch {
1219 #[derive(serde::Serialize)]
1222 struct Tagged<'a> {
1223 query: &'a str,
1224 #[serde(flatten)]
1225 hit: &'a crate::search::Hit,
1226 }
1227 let rows: Vec<Tagged> = hits
1228 .iter()
1229 .map(|hit| Tagged {
1230 query: args.query,
1231 hit,
1232 })
1233 .collect();
1234 if let Some(code) = emit_rows(args.out, &rows) {
1235 return Some(code);
1236 }
1237 } else if let Some(code) = emit_rows(args.out, hits) {
1238 return Some(code);
1239 }
1240 if args.out != Output::Text {
1241 return None;
1242 }
1243 drop(render_span);
1244 let color = match_color();
1245 let c = color.as_deref();
1246 let query = args.query;
1247 if args.show {
1248 eprintln!(
1250 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1251 hits.len()
1252 );
1253 }
1254 for hit in hits {
1255 let name = hl(&hit.name, query, c);
1258 let qualified = match &hit.parent {
1259 Some(p) => format!("{name} · {p}"),
1260 None => name,
1261 };
1262 println!(
1263 "{}:{} {} {}",
1264 hl_path(&hit.file, query, c),
1265 hit.line,
1266 hit.kind,
1267 qualified
1268 );
1269 if let Some(sig) = &hit.signature {
1270 println!(" {}", hl(sig, query, c));
1271 }
1272 if args.explain {
1273 let parts: Vec<String> = hit
1274 .features
1275 .iter()
1276 .map(|f| format!("{} {:.0}", f.name, f.value))
1277 .collect();
1278 println!(
1279 " confidence {:.2} · score {:.0} = {}",
1280 hit.confidence,
1281 hit.score,
1282 parts.join(" + ")
1283 );
1284 }
1285 }
1286 None
1287}
1288
1289fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1293 use std::io::{IsTerminal, Write};
1294 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1295 return hits.first();
1296 }
1297 let mut err = std::io::stderr();
1298 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1299 for (i, h) in hits.iter().enumerate() {
1300 let _ = writeln!(
1301 err,
1302 " {}. {}:{} {} {}",
1303 i + 1,
1304 h.file,
1305 h.line,
1306 h.kind,
1307 h.name
1308 );
1309 }
1310 let _ = write!(err, "rq> ");
1311 let _ = err.flush();
1312 let mut line = String::new();
1313 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1314 return None; }
1316 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1317}
1318
1319fn parse_choice(input: &str, n: usize) -> Option<usize> {
1322 let s = input.trim();
1323 if s.is_empty() {
1324 return Some(0);
1325 }
1326 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1327 (i < n).then_some(i)
1328}
1329
1330fn finish_open(
1334 store: &mut Store,
1335 hits: &[crate::search::Hit],
1336 query: &str,
1337 current: Option<i64>,
1338 root: Option<&std::path::Path>,
1339 no_record: bool,
1340) -> ExitCode {
1341 let Some(hit) = choose_hit(hits) else {
1342 return ExitCode::SUCCESS; };
1344
1345 if !no_record {
1348 let _ = store.record_event(
1349 "select",
1350 Some(&query.to_ascii_lowercase()),
1351 current,
1352 Some(&hit.file),
1353 Some(hit.line),
1354 None,
1355 );
1356 deferred_maintenance(store);
1357 }
1358
1359 let target = match root {
1362 Some(r) => r.join(&hit.file),
1363 None => PathBuf::from(&hit.file),
1364 };
1365 launch_editor(&target, hit.line)
1366}
1367
1368fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1372 use std::os::unix::process::CommandExt;
1373 let loc = format!("{}:{}", file.display(), line);
1374 match open_command(file, line, &loc) {
1375 Some((prog, args)) => {
1376 let err = std::process::Command::new(&prog).args(&args).exec();
1378 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1379 }
1380 None => {
1381 println!("{loc}");
1382 ExitCode::SUCCESS
1383 }
1384 }
1385}
1386
1387fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1391 let fstr = file.to_string_lossy().into_owned();
1392
1393 if let Some(t) = std::env::var_os("RQ_OPEN") {
1394 let t = t.to_string_lossy();
1395 let mut parts = t.split_whitespace().map(|p| {
1396 p.replace("{file}", &fstr)
1397 .replace("{line}", &line.to_string())
1398 .replace("{}", loc)
1399 });
1400 if let Some(prog) = parts.next() {
1401 return Some((prog, parts.collect()));
1402 }
1403 }
1404
1405 if on_path("code") {
1406 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1407 }
1408
1409 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1410 let ed = ed.to_string_lossy().into_owned();
1411 let l = ed.to_ascii_lowercase();
1412 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1414 .iter()
1415 .any(|e| l.contains(e))
1416 {
1417 return Some((ed, vec![format!("+{line}"), fstr]));
1418 }
1419 return Some((ed, vec![fstr]));
1420 }
1421
1422 None
1423}
1424
1425fn on_path(prog: &str) -> bool {
1427 std::env::var_os("PATH")
1428 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1429}
1430
1431fn unix_now() -> i64 {
1441 std::time::SystemTime::now()
1442 .duration_since(std::time::UNIX_EPOCH)
1443 .map(|d| d.as_secs() as i64)
1444 .unwrap_or(0)
1445}
1446
1447const BRANCH_FILES_TTL_SECS: i64 = 15;
1453
1454struct BranchRefresh {
1458 handle: std::thread::JoinHandle<Vec<String>>,
1459 identity: String,
1460 stamp: String,
1461}
1462
1463impl BranchRefresh {
1464 fn store(self, store: &Store) {
1466 let Ok(files) = self.handle.join() else {
1467 return;
1468 };
1469 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1470 }
1471}
1472
1473fn cached_branch_files(
1487 store: &Store,
1488 root: &std::path::Path,
1489) -> (Vec<String>, Option<BranchRefresh>) {
1490 let identity = resolve_identity(store, root);
1491 let stamp = crate::index::branch_files_stamp(root);
1492 let cached = store.branch_files_get(&identity).ok().flatten();
1493 let now = unix_now();
1494
1495 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1496 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1497 return (files.clone(), None);
1498 }
1499 let owned_root = root.to_path_buf();
1500 let refresh = BranchRefresh {
1501 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1502 identity,
1503 stamp: stamp.clone(),
1504 };
1505 return (files.clone(), Some(refresh));
1506 }
1507
1508 let files = crate::index::branch_changed_files(root);
1510 if let Some(stamp) = stamp {
1511 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1512 }
1513 (files, None)
1514}
1515
1516fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1525 let Some(head) = indexed_head else {
1526 return true;
1527 };
1528 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1529}
1530
1531#[allow(clippy::too_many_arguments)]
1539fn settle_warm(
1540 store: &Store,
1541 staleness: Option<std::thread::JoinHandle<bool>>,
1542 was_warming: bool,
1543 warming_ok: bool,
1544 root: Option<&std::path::Path>,
1545 active: &[String],
1546 query: &str,
1547 budget: Duration,
1548 no_wait: bool,
1549 identity: Option<&str>,
1550) -> bool {
1551 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1554 if changed
1564 && !no_wait
1565 && !warm_detach_enabled()
1566 && let Some(r) = root
1567 && let Ok(mut idx) = open_store()
1568 {
1569 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1570 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1571 }
1572 maybe_detach_warm(
1573 store,
1574 warming_ok && (was_warming || changed),
1575 changed,
1576 root,
1577 identity,
1578 );
1579 changed && warm_detach_enabled()
1583}
1584
1585fn answer_warm_budget() -> Duration {
1594 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1595}
1596
1597fn deferred_warm_budget() -> Duration {
1600 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1601}
1602
1603fn live_fallback_budget() -> Duration {
1606 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1607}
1608
1609fn warm_bg_budget() -> Duration {
1612 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1613}
1614
1615fn warm_detach_enabled() -> bool {
1619 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1620}
1621
1622fn wait_budget() -> Duration {
1631 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1632}
1633
1634fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1639 let s = s.trim();
1640 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1641 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1643 (n, 1.0)
1644 } else if let Some(n) = s.strip_suffix('s') {
1645 (n, 1_000.0)
1646 } else if let Some(n) = s.strip_suffix('m') {
1647 (n, 60_000.0)
1648 } else {
1649 (s, 1_000.0)
1651 };
1652 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1653 if !val.is_finite() || val < 0.0 {
1654 return Err(bad());
1655 }
1656 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1657}
1658
1659static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1663
1664extern "C" fn on_sigint(_: libc::c_int) {
1665 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1667}
1668
1669fn install_interrupt_handler() {
1672 static ONCE: std::sync::Once = std::sync::Once::new();
1673 ONCE.call_once(|| unsafe {
1674 let mut action: libc::sigaction = std::mem::zeroed();
1675 action.sa_sigaction = on_sigint as *const () as usize;
1676 libc::sigemptyset(&mut action.sa_mask);
1677 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1678 });
1679}
1680
1681fn stderr_interactive() -> bool {
1685 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1686}
1687
1688fn show_progress(out: Output, interactive: bool) -> bool {
1693 interactive && matches!(out, Output::Text)
1694}
1695
1696fn repo_label(root: Option<&std::path::Path>) -> String {
1699 root.and_then(|r| r.file_name())
1700 .map(|n| n.to_string_lossy().into_owned())
1701 .unwrap_or_else(|| "repo".into())
1702}
1703
1704fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1708 let files = identity
1709 .and_then(|id| store.repository_id(id).ok().flatten())
1710 .and_then(|rid| store.repo_totals(rid).ok())
1711 .map_or(0, |(f, _)| f);
1712 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1713 let _ = std::io::stderr().flush();
1714}
1715
1716fn clear_progress() {
1718 eprint!("\r\x1b[K");
1719 let _ = std::io::stderr().flush();
1720}
1721
1722fn env_budget(var: &str, default_ms: u64) -> Duration {
1726 let ms = std::env::var(var)
1727 .ok()
1728 .and_then(|v| v.parse().ok())
1729 .unwrap_or(default_ms);
1730 Duration::from_millis(ms)
1731}
1732
1733const AGGREGATE_BATCH: usize = 256;
1736
1737const KEEP_RECENT_EVENTS: i64 = 200;
1740
1741fn deferred_maintenance(store: &mut Store) {
1744 let _ = store.aggregate_events(AGGREGATE_BATCH);
1745 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1746}
1747
1748fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1751 let mut store = match open_store() {
1752 Ok(s) => s,
1753 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1754 };
1755 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1756 let identity = crate::index::detect_identity(&cwd).to_string();
1757 let repo_id = store.repository_id(&identity).ok().flatten();
1758
1759 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1762 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1763 None => file.to_string(),
1764 };
1765 let query_norm = query.map(|q| q.to_ascii_lowercase());
1766
1767 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1768 {
1769 return fail(format_args!("rq record: {e}"));
1770 }
1771 deferred_maintenance(&mut store);
1772 ExitCode::SUCCESS
1773}
1774
1775fn hit_file_roots(
1781 store: &Store,
1782 repo_identity: &str,
1783 cwd: Option<&std::path::Path>,
1784) -> Vec<PathBuf> {
1785 let mut roots: Vec<PathBuf> = store
1786 .repository_id(repo_identity)
1787 .ok()
1788 .flatten()
1789 .map(|id| store.checkout_roots(id).unwrap_or_default())
1790 .unwrap_or_default()
1791 .into_iter()
1792 .map(PathBuf::from)
1793 .collect();
1794 if let Some(c) = cwd {
1795 let c = c.to_path_buf();
1796 if !roots.contains(&c) {
1797 roots.push(c);
1798 }
1799 }
1800 roots
1801}
1802
1803fn read_signature(
1806 store: &Store,
1807 repo_identity: &str,
1808 file: &str,
1809 line: i64,
1810 cwd: Option<&std::path::Path>,
1811) -> Option<String> {
1812 hit_file_roots(store, repo_identity, cwd)
1813 .into_iter()
1814 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1815}
1816
1817const SHOW_CONFIDENCE: f64 = 0.85;
1821
1822fn show_top_definition(
1826 store: &Store,
1827 hits: &mut [crate::search::Hit],
1828 query: &str,
1829 out: Output,
1830 cwd: Option<&std::path::Path>,
1831) -> Option<ExitCode> {
1832 let top = hits.first()?;
1833 if top.confidence < SHOW_CONFIDENCE {
1834 return None; }
1836 let end = top.end_line.unwrap_or(top.line);
1837 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1838 hits[0].body = body;
1839 let top = &hits[0];
1840 match out {
1841 Output::Json | Output::Ndjson => {
1842 return Some(emit_json(out, top));
1844 }
1845 Output::Text => {
1846 let color = match_color();
1847 let c = color.as_deref();
1848 let name = hl(&top.name, query, c);
1849 let qualified = match &top.parent {
1850 Some(p) => format!("{name} · {p}"),
1851 None => name,
1852 };
1853 println!(
1854 "{}:{} {} {}",
1855 hl_path(&top.file, query, c),
1856 top.line,
1857 top.kind,
1858 qualified
1859 );
1860 match (&top.body, &top.signature) {
1861 (Some(body), _) => println!("{body}"),
1862 (None, Some(sig)) => println!("{sig}"),
1864 (None, None) => {}
1865 }
1866 }
1867 }
1868 Some(ExitCode::SUCCESS)
1869}
1870
1871fn read_span(
1874 store: &Store,
1875 repo_identity: &str,
1876 file: &str,
1877 start: i64,
1878 end: i64,
1879 cwd: Option<&std::path::Path>,
1880) -> Option<String> {
1881 hit_file_roots(store, repo_identity, cwd)
1882 .into_iter()
1883 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1884}
1885
1886fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1889 let s = usize::try_from(start).ok()?.checked_sub(1)?;
1890 let lines: Vec<&str> = content.lines().collect();
1891 if s >= lines.len() {
1892 return None;
1893 }
1894 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1895 Some(lines[s..e].join("\n"))
1896}
1897
1898fn signature_in(content: &str, line: i64) -> Option<String> {
1902 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1903 let l = content.lines().nth(idx)?.trim();
1904 (!l.is_empty()).then(|| l.to_string())
1905}
1906
1907#[derive(serde::Serialize)]
1911struct SymbolOut {
1912 name: String,
1913 kind: String,
1914 language: String,
1915 file: String,
1916 line: i64,
1917 #[serde(skip_serializing_if = "Option::is_none")]
1918 end_line: Option<i64>,
1919 #[serde(skip_serializing_if = "Option::is_none")]
1920 parent: Option<String>,
1921 #[serde(skip_serializing_if = "Option::is_none")]
1922 visibility: Option<String>,
1923 repo: String,
1924 #[serde(skip_serializing_if = "Option::is_none")]
1925 signature: Option<String>,
1926}
1927
1928fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
1933 let mut store = match open_store() {
1934 Ok(s) => s,
1935 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1936 };
1937 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1938 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
1939 let rel = repo_relative(&root, &cwd, file_arg);
1940
1941 let identity = resolve_identity(&store, &root);
1942 let coverage = store.coverage_status(&identity).ok().flatten();
1943 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
1944 let current = store.repository_id(&identity).ok().flatten();
1945 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
1948 let needs_warm = warming_ok
1949 && (coverage.as_deref() != Some("complete")
1950 || worktree_changed(&root, indexed_head.as_deref()));
1951 if needs_warm {
1952 let budget = answer_warm_budget() + deferred_warm_budget();
1954 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
1955 }
1956
1957 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
1958 return emit_symbols(out, &[]); };
1960 let mut rows = match store.symbols_in_file(repo_id, &rel) {
1961 Ok(r) => r,
1962 Err(e) => return fail(format_args!("rq: {e}")),
1963 };
1964 if !kinds.is_empty() {
1965 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
1966 }
1967 if !langs.is_empty() {
1968 rows.retain(|r| langs.iter().any(|l| l == &r.language));
1969 }
1970
1971 let content = hit_file_roots(&store, &identity, Some(&root))
1975 .iter()
1976 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
1977 let syms: Vec<SymbolOut> = rows
1978 .into_iter()
1979 .map(|r| SymbolOut {
1980 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
1981 name: r.name,
1982 kind: r.kind,
1983 language: r.language,
1984 file: r.file,
1985 line: r.line,
1986 end_line: r.end_line,
1987 parent: r.parent,
1988 visibility: r.visibility,
1989 repo: r.repo_identity,
1990 })
1991 .collect();
1992 emit_symbols(out, &syms)
1993}
1994
1995fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
1998 if syms.is_empty() {
1999 match out {
2000 Output::Json | Output::Ndjson => {
2001 let obj = serde_json::json!({ "status": "no_match" });
2002 let _ = emit_json(out, &obj); }
2004 Output::Text => eprintln!("no symbols"),
2005 }
2006 return ExitCode::FAILURE;
2007 }
2008 if let Some(code) = emit_rows(out, syms) {
2009 return code;
2010 }
2011 match out {
2012 Output::Json | Output::Ndjson => {}
2013 Output::Text => {
2014 for s in syms {
2015 let qualified = match &s.parent {
2016 Some(p) => format!("{} · {p}", s.name),
2017 None => s.name.clone(),
2018 };
2019 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2020 if let Some(sig) = &s.signature {
2021 println!(" {sig}");
2022 }
2023 }
2024 }
2025 }
2026 ExitCode::SUCCESS
2027}
2028
2029fn keyword_kind(token: &str) -> Option<&'static str> {
2034 match token.to_ascii_lowercase().as_str() {
2035 "class" => Some("class"),
2036 "module" => Some("module"),
2037 "method" => Some("method"),
2038 "function" | "fn" => Some("function"),
2039 "struct" | "type" => Some("struct"),
2040 "enum" => Some("enum"),
2041 "trait" | "interface" => Some("trait"),
2042 _ => None,
2043 }
2044}
2045
2046fn split_kind_keyword(
2052 target: String,
2053 dirs: Vec<String>,
2054) -> (Option<&'static str>, String, Vec<String>) {
2055 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2058 let rest = rest.trim();
2059 if let Some(k) = keyword_kind(head)
2060 && !rest.is_empty()
2061 {
2062 return (Some(k), rest.to_string(), dirs);
2063 }
2064 } else if let Some(k) = keyword_kind(&target)
2065 && let Some((query, extra)) = dirs.split_first()
2066 {
2067 return (Some(k), query.clone(), extra.to_vec());
2069 }
2070 (None, target, dirs)
2071}
2072
2073fn canonical_kind(s: &str) -> String {
2076 match s.to_ascii_lowercase().as_str() {
2077 "c" | "class" => "class",
2078 "m" | "method" => "method",
2079 "f" | "fn" | "func" | "function" => "function",
2080 "mod" | "module" => "module",
2081 "s" | "struct" | "type" => "struct",
2082 "e" | "enum" => "enum",
2083 "t" | "trait" | "interface" => "trait",
2084 other => return other.to_string(),
2085 }
2086 .to_string()
2087}
2088
2089fn canonical_langs(s: &str) -> Vec<String> {
2096 let t = s.to_ascii_lowercase();
2097 let alias = match t.as_str() {
2098 "rb" => Some("ruby"),
2099 "rs" => Some("rust"),
2100 "golang" => Some("go"),
2101 "ts" | "tsx" => Some("typescript"),
2102 "js" | "jsx" => Some("javascript"),
2103 _ => None,
2104 };
2105 let matched: Vec<String> = crate::lang::languages()
2106 .into_iter()
2107 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2108 .map(str::to_string)
2109 .collect();
2110 if matched.is_empty() { vec![t] } else { matched }
2111}
2112
2113fn match_color() -> Option<String> {
2117 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2118 return None;
2119 }
2120 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2121 gc.split(':').find_map(|e| {
2122 e.strip_prefix("mt=")
2123 .or_else(|| e.strip_prefix("ms="))
2124 .filter(|v| !v.is_empty())
2125 .map(str::to_string)
2126 })
2127 });
2128 Some(style.unwrap_or_else(|| "1;31".to_string()))
2129}
2130
2131fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2134 match color {
2135 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2136 None => text.to_string(),
2137 }
2138}
2139
2140fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2143 let Some(c) = color else {
2144 return path.to_string();
2145 };
2146 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2147 let base_start = path[..base_byte].chars().count();
2148 let stem = crate::search::path_stem(path);
2152 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2153 .into_iter()
2154 .map(|p| p + base_start)
2155 .collect();
2156 highlight(path, &positions, c)
2157}
2158
2159fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2162 if positions.is_empty() {
2163 return text.to_string();
2164 }
2165 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2166 let mut out = String::new();
2167 let mut on = false;
2168 for (i, c) in text.chars().enumerate() {
2169 match (matched.contains(&i), on) {
2170 (true, false) => {
2171 out.push_str("\x1b[");
2172 out.push_str(color);
2173 out.push('m');
2174 on = true;
2175 }
2176 (false, true) => {
2177 out.push_str("\x1b[0m");
2178 on = false;
2179 }
2180 _ => {}
2181 }
2182 out.push(c);
2183 }
2184 if on {
2185 out.push_str("\x1b[0m");
2186 }
2187 out
2188}
2189
2190fn under_any(file: &str, paths: &[String]) -> bool {
2194 paths.iter().any(|p| {
2195 let p = p.trim_start_matches("./").trim_end_matches('/');
2196 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2197 })
2198}
2199
2200fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2202 let p = std::path::Path::new(file);
2203 let abs = if p.is_absolute() {
2204 p.to_path_buf()
2205 } else {
2206 cwd.join(p)
2207 };
2208 let abs = abs.canonicalize().unwrap_or(abs);
2209 abs.strip_prefix(root)
2210 .map(|r| r.to_string_lossy().into_owned())
2211 .unwrap_or_else(|_| file.to_string())
2212}
2213
2214fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2218 use std::collections::HashSet;
2219 let mut seen = HashSet::new();
2220 let mut changed = false;
2221 for hit in hits {
2222 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2223 continue;
2224 }
2225 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2226 continue;
2227 };
2228 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2229 continue;
2230 };
2231 if let Ok(crate::index::Refresh::Updated) =
2232 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2233 {
2234 changed = true;
2235 }
2236 }
2237 changed
2238}
2239
2240fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2246 if let Ok(canon) = cwd.canonicalize() {
2247 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2248 return identity;
2249 }
2250 if crate::index::repo_root(cwd).is_none() {
2251 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2252 }
2253 }
2254 crate::index::detect_identity(cwd).to_string()
2255}
2256
2257fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2258 let explicit = path.is_some();
2259 let target = path.unwrap_or_else(|| PathBuf::from("."));
2260 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2265 let mut subdirs = subdirs.to_vec();
2270 if explicit
2271 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2272 && t != r
2273 && let Ok(rel) = t.strip_prefix(&r)
2274 && !rel.as_os_str().is_empty()
2275 {
2276 subdirs.push(rel.to_string_lossy().into_owned());
2277 }
2278 let mut store = match open_store() {
2279 Ok(s) => s,
2280 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2281 };
2282 let identity = crate::index::detect_identity(&root).to_string();
2283 match crate::index::index_under(&mut store, &root, &subdirs) {
2284 Ok(stats) => {
2285 let subtree = !subdirs.is_empty();
2286 let totals = store
2288 .repository_id(&identity)
2289 .ok()
2290 .flatten()
2291 .and_then(|id| store.repo_totals(id).ok());
2292 match out {
2293 Output::Json | Output::Ndjson => {
2294 let (files, symbols) = match totals {
2295 Some((f, s)) => (Some(f), Some(s)),
2296 None => (None, None),
2297 };
2298 return emit_json(
2299 out,
2300 &serde_json::json!({
2301 "repo": identity,
2302 "scope": if subtree { "subtree" } else { "full" },
2303 "files_added": stats.files_indexed,
2304 "symbols_added": stats.symbols,
2305 "files": files,
2306 "symbols": symbols,
2307 }),
2308 );
2309 }
2310 Output::Text => {
2311 let scope = if subtree { " (subtree seed)" } else { "" };
2312 match totals {
2313 Some((files, symbols)) => println!(
2314 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2315 stats.files_indexed, stats.symbols
2316 ),
2317 None => println!(
2318 "{} file(s)/{} symbol(s) added this run{scope}",
2319 stats.files_indexed, stats.symbols
2320 ),
2321 }
2322 }
2323 }
2324 ExitCode::SUCCESS
2325 }
2326 Err(e) => fail(format_args!("rq --index: {e}")),
2327 }
2328}
2329
2330fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2331 let mut store = match open_store() {
2332 Ok(s) => s,
2333 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2334 };
2335
2336 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2340 let root = crate::index::repo_root(&path).unwrap_or(path);
2341 let from_path = crate::index::detect_identity(&root).to_string();
2342 let resolved = match store.repository_id(&from_path) {
2343 Ok(Some(id)) => Some((from_path.clone(), id)),
2344 Ok(None) => target.as_deref().and_then(|s| {
2345 store
2346 .repository_id(s)
2347 .ok()
2348 .flatten()
2349 .map(|id| (s.to_string(), id))
2350 }),
2351 Err(e) => return fail(format_args!("rq --drop: {e}")),
2352 };
2353
2354 let Some((identity, repo_id)) = resolved else {
2355 return match out {
2357 Output::Text => {
2358 println!("not indexed: {from_path}");
2359 ExitCode::SUCCESS
2360 }
2361 _ => emit_json(
2362 out,
2363 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2364 ),
2365 };
2366 };
2367
2368 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2369 match store.drop_repository(repo_id) {
2370 Ok(()) => match out {
2371 Output::Text => {
2372 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2373 ExitCode::SUCCESS
2374 }
2375 _ => emit_json(
2376 out,
2377 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2378 ),
2379 },
2380 Err(e) => fail(format_args!("rq --drop: {e}")),
2381 }
2382}
2383
2384fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2388 let rendered = if out == Output::Json {
2389 serde_json::to_string_pretty(value)
2390 } else {
2391 serde_json::to_string(value)
2392 };
2393 match rendered {
2394 Ok(s) => {
2395 println!("{s}");
2396 ExitCode::SUCCESS
2397 }
2398 Err(e) => fail(format_args!("rq: {e}")),
2399 }
2400}
2401
2402fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2406 match out {
2407 Output::Json => match serde_json::to_string_pretty(rows) {
2408 Ok(s) => println!("{s}"),
2409 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2410 },
2411 Output::Ndjson => {
2412 for r in rows {
2413 match serde_json::to_string(r) {
2414 Ok(line) => println!("{line}"),
2415 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2416 }
2417 }
2418 }
2419 Output::Text => {}
2420 }
2421 None
2422}
2423
2424fn cmd_status(out: Output) -> ExitCode {
2425 let store = match open_store() {
2426 Ok(s) => s,
2427 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2428 };
2429 let rows = match store.coverage_overview() {
2430 Ok(rows) => rows,
2431 Err(e) => return fail(format_args!("rq --status: {e}")),
2432 };
2433 if let Some(code) = emit_rows(out, &rows) {
2434 return code;
2435 }
2436 match out {
2437 Output::Json | Output::Ndjson => {}
2438 Output::Text if rows.is_empty() => {
2439 println!("no repositories indexed yet (try `rq --index`)");
2440 }
2441 Output::Text => {
2442 for r in &rows {
2443 println!(
2444 "{:<10} {:>6} files {:>7} symbols {}",
2445 r.status, r.files, r.symbols, r.identity
2446 );
2447 }
2448 }
2449 }
2450 ExitCode::SUCCESS
2451}
2452
2453fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2455 let path = db_path()?;
2456 if let Some(parent) = path.parent() {
2457 std::fs::create_dir_all(parent)?;
2458 }
2459 Ok(Store::open(&path)?)
2460}
2461
2462fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2464 if let Ok(p) = std::env::var("RQ_DB") {
2465 return Ok(PathBuf::from(p));
2466 }
2467 let home = std::env::var("HOME")?;
2468 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2469}
2470
2471fn fail(args: std::fmt::Arguments) -> ExitCode {
2472 eprintln!("{args}");
2473 ExitCode::FAILURE
2474}
2475
2476#[cfg(test)]
2477mod tests {
2478 use super::*;
2479
2480 #[test]
2481 fn open_menu_choice_parsing() {
2482 assert_eq!(parse_choice("\n", 5), Some(0));
2484 assert_eq!(parse_choice(" ", 5), Some(0));
2485 assert_eq!(parse_choice("3", 5), Some(2));
2486 assert_eq!(parse_choice("5", 5), Some(4));
2487 assert_eq!(parse_choice("6", 5), None);
2489 assert_eq!(parse_choice("0", 5), None);
2490 assert_eq!(parse_choice("q", 5), None);
2491 }
2492
2493 #[test]
2494 fn wait_duration_parsing() {
2495 use std::time::Duration;
2496 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2498 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2499 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2500 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2501 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2503 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2504 assert!(parse_wait("0s").unwrap().is_zero());
2505 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2507 assert!(parse_wait("2x").is_err());
2509 assert!(parse_wait("").is_err());
2510 assert!(parse_wait("s").is_err());
2511 assert!(parse_wait("-1s").is_err());
2512 }
2513
2514 #[test]
2515 fn leading_kind_keyword_becomes_a_kind_filter() {
2516 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2517 assert_eq!(
2519 split_kind_keyword("class".into(), d(&["Widget"])),
2520 (Some("class"), "Widget".into(), vec![])
2521 );
2522 assert_eq!(
2524 split_kind_keyword("method zoom".into(), vec![]),
2525 (Some("method"), "zoom".into(), vec![])
2526 );
2527 assert_eq!(
2529 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2530 (Some("function"), "Foo::run".into(), vec![])
2531 );
2532 assert_eq!(
2534 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2535 (Some("struct"), "Gadget".into(), d(&["src"]))
2536 );
2537 }
2538
2539 #[test]
2540 fn a_bare_or_non_keyword_query_is_left_alone() {
2541 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2542 assert_eq!(
2544 split_kind_keyword("class".into(), vec![]),
2545 (None, "class".into(), vec![])
2546 );
2547 assert_eq!(
2549 split_kind_keyword("Widget".into(), d(&["app"])),
2550 (None, "Widget".into(), d(&["app"]))
2551 );
2552 assert_eq!(
2554 split_kind_keyword("c".into(), d(&["Foo"])),
2555 (None, "c".into(), d(&["Foo"]))
2556 );
2557 }
2558
2559 #[test]
2560 fn a_language_selects_by_prefix_or_alias() {
2561 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2563 assert_eq!(canonical_langs("t"), ["typescript"]);
2564 assert_eq!(canonical_langs("ts"), ["typescript"]);
2566 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2567 assert_eq!(canonical_langs("rb"), ["ruby"]);
2568 assert_eq!(canonical_langs("COBOL"), ["cobol"]);
2570 }
2571
2572 #[test]
2573 fn a_kind_normalizes_language_specific_spellings() {
2574 assert_eq!(canonical_kind("f"), "function");
2575 assert_eq!(canonical_kind("interface"), "trait");
2577 assert_eq!(canonical_kind("type"), "struct");
2578 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2580 assert_eq!(
2581 split_kind_keyword("interface".into(), d(&["Renderer"])),
2582 (Some("trait"), "Renderer".into(), vec![])
2583 );
2584 }
2585
2586 #[test]
2587 fn highlight_wraps_matched_runs() {
2588 assert_eq!(
2589 highlight("FooThing", &[0, 1, 2], "1;31"),
2590 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2591 );
2592 assert_eq!(
2594 highlight("FooThing", &[0, 3], "1"),
2595 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2596 );
2597 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2599 }
2600
2601 #[test]
2602 fn progress_ui_only_for_an_interactive_text_terminal() {
2603 assert!(show_progress(Output::Text, true));
2605
2606 assert!(!show_progress(Output::Json, true));
2608 assert!(!show_progress(Output::Ndjson, true));
2609
2610 assert!(!show_progress(Output::Text, false));
2612 }
2613
2614 #[test]
2615 fn repo_label_uses_the_directory_name() {
2616 assert_eq!(
2617 repo_label(Some(std::path::Path::new("/src/widgets"))),
2618 "widgets"
2619 );
2620 assert_eq!(repo_label(None), "repo");
2621 }
2622
2623 #[test]
2624 fn hl_path_highlights_the_stem_not_the_extension() {
2625 let out = hl_path(
2628 "app/employees_controller.rb",
2629 "employeescontroller",
2630 Some("1;31"),
2631 );
2632 assert!(
2633 out.starts_with("app/\u{1b}[1;31memployees"),
2634 "stem highlighted: {out:?}"
2635 );
2636 assert!(
2637 out.ends_with("controller\u{1b}[0m.rb"),
2638 "`.rb` left un-highlighted: {out:?}"
2639 );
2640 }
2641}