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)]
76 no_record: bool,
77
78 #[arg(long = "no-wait")]
84 no_wait: bool,
85
86 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
91 wait: Option<Duration>,
92
93 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
98 open: bool,
99
100 #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
104 show: bool,
105
106 #[arg(short = 'j', long)]
108 json: bool,
109
110 #[arg(short = 'J', long, conflicts_with = "json")]
112 ndjson: bool,
113
114 #[arg(short = 'p', long, value_name = "DIR")]
116 path: Vec<String>,
117
118 #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
120 limit: usize,
121
122 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
126 kind: Vec<String>,
127
128 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
132 lang: Vec<String>,
133
134 #[arg(long = "all-repos")]
137 all_repos: bool,
138
139 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
141 index: Option<Option<String>>,
142
143 #[arg(long, conflicts_with_all = ["index", "record"])]
145 status: bool,
146
147 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
150 symbols: Option<String>,
151
152 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
156 drop: bool,
157
158 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
161 record: bool,
162
163 #[arg(long)]
165 file: Option<String>,
166
167 #[arg(long)]
169 line: Option<i64>,
170
171 #[arg(long, default_value = "select")]
173 event: String,
174
175 #[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"])]
179 warm: Option<Option<String>>,
180
181 #[arg(long, value_name = "SHELL")]
183 completions: Option<Shell>,
184
185 #[arg(short = 'v', long)]
188 verbose: bool,
189
190 #[arg(long)]
194 profile: bool,
195
196 #[arg(long, value_name = "N", default_value_t = 0)]
199 jobs: usize,
200}
201
202pub fn run() -> ExitCode {
204 let cli = Cli::parse();
205 crate::trace::enable_from(cli.verbose);
206 crate::profile::enable_from(cli.profile);
207 crate::index::set_parse_jobs(cli.jobs);
208
209 if let Some(shell) = cli.completions {
210 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
211 return ExitCode::SUCCESS;
212 }
213 if let Some(path) = &cli.index {
214 let out = output_format(&cli);
216 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
217 }
218 if let Some(path) = &cli.warm {
219 return cmd_warm(path.as_deref());
220 }
221 if cli.status {
222 return cmd_status(output_format(&cli));
223 }
224 if cli.drop {
225 let out = output_format(&cli);
226 return cmd_drop(cli.target, out);
227 }
228 if cli.record {
229 if !matches!(cli.event.as_str(), "select" | "open") {
231 return fail(format_args!(
232 "rq --record: unknown --event {:?} (expected select or open)",
233 cli.event
234 ));
235 }
236 let file = cli.file.expect("--record requires --file");
238 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
239 }
240 let out = output_format(&cli);
241 let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
242 let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
244 if let Some(file) = &cli.symbols {
245 return cmd_symbols(file, &kinds, &langs, out);
246 }
247 let mut paths = cli.path.clone();
249 match cli.target {
250 Some(target) => {
251 let query = if cli.kind.is_empty() {
254 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
255 if let Some(k) = kw {
256 kinds.push(k.to_string());
257 }
258 paths.extend(dirs);
259 query
260 } else {
261 paths.extend(cli.dirs.clone());
262 target
263 };
264 let mut session = match Session::open() {
265 Ok(s) => s,
266 Err(code) => return code,
267 };
268 cmd_search(
269 &mut session,
270 &SearchArgs {
271 query: &query,
272 explain: cli.explain,
273 out,
274 paths: &paths,
275 kinds: &kinds,
276 langs: &langs,
277 want: cli.limit,
278 no_record: cli.no_record,
279 no_wait: cli.no_wait,
280 wait: cli.wait,
281 open: cli.open,
282 all_repos: cli.all_repos,
283 show: cli.show,
284 batch: false,
285 },
286 )
287 }
288 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
291 None => {
293 let _ = Cli::command().print_long_help();
294 ExitCode::SUCCESS
295 }
296 }
297}
298
299#[derive(Clone, Copy, PartialEq)]
301enum Output {
302 Text,
303 Json,
304 Ndjson,
305}
306
307fn output_format(cli: &Cli) -> Output {
308 if cli.ndjson {
309 Output::Ndjson
310 } else if cli.json {
311 Output::Json
312 } else {
313 Output::Text
314 }
315}
316
317const PATH_HEADROOM: usize = 200;
320
321const POLL_INTERVAL: Duration = Duration::from_millis(100);
328
329const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
333
334const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
338
339struct SearchArgs<'a> {
341 query: &'a str,
342 explain: bool,
343 out: Output,
344 paths: &'a [String],
345 kinds: &'a [String],
346 langs: &'a [String],
347 want: usize,
349 no_record: bool,
350 no_wait: bool,
352 wait: Option<Duration>,
355 open: bool,
356 all_repos: bool,
357 batch: bool,
361 show: bool,
362}
363
364struct Session {
375 store: Store,
376 cwd: Option<PathBuf>,
377 cwd_is_git: bool,
378 root: Option<PathBuf>,
379 active_paths: Vec<String>,
380 branch_refresh: Option<BranchRefresh>,
381 identity: Option<String>,
382 coverage: Option<String>,
383}
384
385impl Session {
386 fn open() -> std::result::Result<Session, ExitCode> {
388 let open_span = crate::profile::span("store open");
389 let store = match open_store() {
390 Ok(s) => s,
391 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
392 };
393 drop(open_span);
394 let git_span = crate::profile::span("setup: git root");
395 let cwd = std::env::current_dir().ok();
396 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
397
398 let root = cwd
404 .as_deref()
405 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
406 drop(git_span);
407
408 let mut branch_span = crate::profile::span("setup: branch files");
411 let (active_paths, branch_refresh) = match &root {
412 Some(c) if cwd_is_git => cached_branch_files(&store, c),
413 _ => (Vec::new(), None),
414 };
415 branch_span.note(|| {
416 let how = if branch_refresh.is_some() {
417 "cached, refreshing alongside"
418 } else {
419 "cached"
420 };
421 format!("{} changed, {how}", active_paths.len())
422 });
423 drop(branch_span);
424
425 let mut identity_span = crate::profile::span("setup: identity");
430 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
431 let coverage = identity
432 .as_deref()
433 .and_then(|id| store.coverage_status(id).ok())
434 .flatten();
435 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
436 drop(identity_span);
437 Ok(Session {
438 store,
439 cwd,
440 cwd_is_git,
441 root,
442 active_paths,
443 branch_refresh,
444 identity,
445 coverage,
446 })
447 }
448}
449
450fn cmd_batch(
465 cli: &Cli,
466 out: Output,
467 paths: &[String],
468 kinds: &[String],
469 langs: &[String],
470) -> ExitCode {
471 if out == Output::Json {
472 return fail(format_args!(
473 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
474 where each line carries the query it answers"
475 ));
476 }
477 if cli.open || cli.show {
478 return fail(format_args!(
479 "rq: --open and --show act on a single result, not a stream of queries"
480 ));
481 }
482
483 use std::io::BufRead;
484 let queries: Vec<String> = std::io::stdin()
485 .lock()
486 .lines()
487 .map_while(std::result::Result::ok)
488 .map(|l| l.trim().to_string())
489 .filter(|l| !l.is_empty())
490 .collect();
491 if queries.is_empty() {
495 let _ = Cli::command().print_long_help();
496 return ExitCode::SUCCESS;
497 }
498
499 let mut session = match Session::open() {
500 Ok(s) => s,
501 Err(code) => return code,
502 };
503
504 if !cli.no_wait
507 && session.coverage.as_deref() != Some("complete")
508 && let Some(root) = session.root.clone()
509 {
510 {
511 let budget = cli.wait.unwrap_or_else(wait_budget);
512 crate::trace!(
513 "batch: warming {} queries' worth of index first",
514 queries.len()
515 );
516 let active = session.active_paths.clone();
517 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
518 session.coverage = session
519 .identity
520 .as_deref()
521 .and_then(|id| session.store.coverage_status(id).ok())
522 .flatten();
523 }
524 }
525
526 let mut worst = ExitCode::SUCCESS;
527 let mut any_hit = false;
528 for query in &queries {
529 let code = cmd_search(
530 &mut session,
531 &SearchArgs {
532 query,
533 explain: cli.explain,
534 out,
535 paths,
536 kinds,
537 langs,
538 want: cli.limit,
539 no_record: cli.no_record,
540 no_wait: true,
544 wait: cli.wait,
545 open: false,
546 all_repos: cli.all_repos,
547 show: false,
548 batch: true,
549 },
550 );
551 if code == ExitCode::SUCCESS {
552 any_hit = true;
553 } else {
554 worst = code;
555 }
556 }
557 if any_hit { ExitCode::SUCCESS } else { worst }
560}
561
562fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
563 let &SearchArgs {
564 query,
565 out,
566 want,
567 no_record,
568 no_wait,
569 wait,
570 open,
571 all_repos,
572 show,
573 ..
574 } = args;
575 let wait_budget = wait.unwrap_or_else(wait_budget);
578 let no_wait = no_wait || wait_budget.is_zero();
579 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
582 want
583 } else {
584 (want * 20).max(PATH_HEADROOM)
585 };
586 let _timer = crate::trace::Timer::start("search done");
587 let profile_started = std::time::Instant::now();
588 let t_setup = std::time::Instant::now();
589 let setup_span = crate::profile::span("setup");
591 let Session {
594 store,
595 cwd,
596 cwd_is_git,
597 root,
598 active_paths,
599 branch_refresh,
600 identity,
601 coverage,
602 } = session;
603 let cwd_is_git = *cwd_is_git;
604
605 let known = coverage.is_some();
613 let warming_ok = cwd_is_git || known;
614 if crate::trace::enabled() {
615 crate::trace!(
616 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
617 root.as_deref().map_or("?".into(), crate::trace::abbrev),
618 identity.as_deref().unwrap_or("none"),
619 coverage.as_deref().unwrap_or("none"),
620 active_paths.len(),
621 );
622 }
623 let repo_span = crate::profile::span("setup: repo state");
624 let current = identity
625 .as_deref()
626 .and_then(|id| store.repository_id(id).ok().flatten());
627 let only_repo = if all_repos { None } else { current };
630 let active = crate::search::ActiveFiles::new(active_paths.clone());
631
632 drop(repo_span);
633 let warm_span = crate::profile::span("setup: warm decision");
634
635 let warm_budget = if warm_detach_enabled() {
642 answer_warm_budget()
643 } else {
644 answer_warm_budget() + deferred_warm_budget()
645 };
646 let was_warming = coverage.as_deref() != Some("complete");
647
648 let indexed_head = (!was_warming)
659 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
660 .flatten();
661 let staleness = (!was_warming && warming_ok && !args.batch)
664 .then(|| root.clone())
665 .flatten()
666 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
667 let want_warm = warming_ok && was_warming && root.is_some();
670
671 let block = want_warm && was_warming && !no_wait;
685 let progress_ui = block && show_progress(out, stderr_interactive());
690 let indexer_budget = if block { wait_budget } else { warm_budget };
691 if progress_ui {
692 install_interrupt_handler();
693 }
694
695 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
698 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
699 crate::trace!(
700 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
701 crate::index::parse_jobs()
702 );
703 let root = root.clone().expect("checked");
704 let active = active_paths.clone();
705 let q = query.to_string();
706 let warm_done = std::sync::Arc::clone(&warm_done);
707 std::thread::spawn(move || {
708 if let Ok(mut idx) = open_store() {
709 let _ = if block {
711 crate::index::index_budgeted_cancellable(
714 &mut idx,
715 &root,
716 &active,
717 indexer_budget,
718 Some(&q),
719 &INTERRUPTED,
720 )
721 } else {
722 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
723 };
724 }
725 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
726 })
727 });
728
729 crate::trace!(
736 "setup (open + repo detect + warm decision): {} ms",
737 t_setup.elapsed().as_millis()
738 );
739 let poll_start = std::time::Instant::now();
740 let deadline = if progress_ui {
744 None
745 } else if block {
746 Some(poll_start + wait_budget)
747 } else {
748 Some(poll_start + answer_warm_budget())
749 };
750 drop(warm_span);
751 let polling = indexer.is_some() && was_warming;
752 drop(setup_span);
756 let mut query_span = crate::profile::span("query");
757 let label = repo_label(root.as_deref());
758 let mut drew_progress = false;
759 let mut last_draw = poll_start;
760 let mut hits = loop {
761 match crate::search::search(store, query, current, only_repo, &active, limit) {
762 Ok(h) => {
763 let confident = h.first().is_some_and(|hit| {
764 hit.features
765 .iter()
766 .any(|f| matches!(f.name, "exact" | "prefix"))
767 });
768 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
769 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
770 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
771 if !polling || confident || warm_finished || stopped || timed_out {
772 break h;
773 }
774 if progress_ui
775 && poll_start.elapsed() >= HEADS_UP_DELAY
776 && last_draw.elapsed() >= PROGRESS_REDRAW
777 {
778 draw_progress(store, identity.as_deref(), &label);
779 drew_progress = true;
780 last_draw = std::time::Instant::now();
781 }
782 }
783 Err(e) => {
784 if let Some(h) = indexer {
785 let _ = h.join();
786 }
787 return fail(format_args!("rq: {e}"));
788 }
789 }
790 std::thread::sleep(POLL_INTERVAL);
791 };
792 query_span.note(|| {
793 if polling {
794 "polled a warming index".to_string()
795 } else {
796 String::new()
797 }
798 });
799 drop(query_span);
800 if drew_progress {
801 clear_progress();
802 }
803 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
805
806 if !hits.is_empty() && revalidate_top(store, &hits) {
808 hits = crate::search::search(store, query, current, only_repo, &active, limit)
809 .unwrap_or_default();
810 }
811
812 if !hits.iter().any(strong)
816 && indexer.is_none()
817 && coverage.is_none()
818 && let Some(root) = &root
819 {
820 let tail = live_fallback(root, query, limit);
821 hits = crate::search::merge(hits, tail, limit);
822 }
823
824 apply_gates(query, &mut hits);
825 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
826
827 if hits.is_empty() {
828 if block {
830 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
831 }
832 if let Some(h) = indexer {
833 let _ = h.join();
834 }
835 let mut incomplete = (block || no_wait)
842 && identity
843 .as_deref()
844 .and_then(|id| store.coverage_status(id).ok().flatten())
845 .as_deref()
846 != Some("complete");
847 incomplete |= settle_warm(
857 store,
858 staleness,
859 was_warming,
860 warming_ok,
861 root.as_deref(),
862 active_paths,
863 query,
864 warm_budget,
865 no_wait,
866 identity.as_deref(),
867 );
868 return no_match_code(out, query, interrupted, incomplete);
869 }
870
871 for hit in &mut hits {
874 hit.signature = read_signature(
875 store,
876 &hit.repo_identity,
877 &hit.file,
878 hit.line,
879 cwd.as_deref(),
880 );
881 }
882 attach_confidence(&mut hits);
883
884 if show && let Some(code) = show_top_definition(store, &mut hits, query, out, cwd.as_deref()) {
887 return code;
888 }
889
890 if open {
894 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
895 }
896
897 if let Some(code) = render_hits(args, &hits) {
898 return code;
899 }
900
901 if crate::profile::enabled() {
904 let total = profile_started.elapsed();
905 if args.out == Output::Text {
906 for line in crate::profile::report(total) {
907 eprintln!("{line}");
908 }
909 } else {
910 eprintln!("{}", crate::profile::json(total));
913 }
914 }
915
916 if let Some(refresh) = branch_refresh.take() {
923 refresh.store(store);
924 }
925
926 if !no_record {
931 let _ = store.record_event(
932 "search",
933 Some(&query.to_ascii_lowercase()),
934 current,
935 None,
936 None,
937 None,
938 );
939 }
940 deferred_maintenance(store);
941
942 if block {
947 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
948 }
949 if let Some(h) = indexer {
950 let _ = h.join();
951 }
952 let _ = settle_warm(
953 store,
954 staleness,
955 was_warming,
956 warming_ok,
957 root.as_deref(),
958 active_paths,
959 query,
960 warm_budget,
961 no_wait,
962 identity.as_deref(),
963 );
964
965 ExitCode::SUCCESS
966}
967
968fn maybe_detach_warm(
971 store: &Store,
972 want_warm: bool,
973 changed: bool,
974 root: Option<&std::path::Path>,
975 identity: Option<&str>,
976) {
977 if !warm_detach_enabled() || !want_warm {
978 return;
979 }
980 let (Some(root), Some(id)) = (root, identity) else {
981 return;
982 };
983 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
987 return; }
989 spawn_detached_warm(root);
990}
991
992fn spawn_detached_warm(root: &std::path::Path) {
996 use std::os::unix::process::CommandExt;
997 let Ok(exe) = std::env::current_exe() else {
998 return;
999 };
1000 let mut cmd = std::process::Command::new(exe);
1001 cmd.arg("--warm")
1002 .arg(root)
1003 .stdin(std::process::Stdio::null())
1004 .stdout(std::process::Stdio::null())
1005 .stderr(std::process::Stdio::null())
1006 .process_group(0);
1007 match cmd.spawn() {
1008 Ok(child) => crate::trace!(
1009 "background warm (detached): pid {} for {}",
1010 child.id(),
1011 crate::trace::abbrev(root)
1012 ),
1013 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1014 }
1015}
1016
1017const WARM_LOCK_TTL_SECS: i64 = 600;
1020
1021fn cmd_warm(path: Option<&str>) -> ExitCode {
1026 #[cfg(target_os = "macos")]
1029 unsafe extern "C" {
1030 fn setiopolicy_np(
1033 iotype: libc::c_int,
1034 scope: libc::c_int,
1035 policy: libc::c_int,
1036 ) -> libc::c_int;
1037 }
1038 unsafe {
1039 libc::nice(10);
1040 #[cfg(target_os = "macos")]
1041 setiopolicy_np(0, 0, 3);
1042 }
1043 let mut store = match open_store() {
1044 Ok(s) => s,
1045 Err(_) => return ExitCode::FAILURE,
1046 };
1047 let start = path
1048 .map(PathBuf::from)
1049 .or_else(|| std::env::current_dir().ok())
1050 .unwrap_or_else(|| PathBuf::from("."));
1051 let root = crate::index::repo_root(&start).unwrap_or(start);
1052 let identity = resolve_identity(&store, &root);
1053
1054 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1057 && pid != std::process::id()
1058 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1059 && now_secs() - ts < WARM_LOCK_TTL_SECS
1060 {
1061 return ExitCode::SUCCESS;
1062 }
1063 let _ = store.set_warm_lock(&identity, std::process::id());
1064
1065 let deadline = std::time::Instant::now() + warm_bg_budget();
1068 let active = crate::index::branch_changed_files(&root);
1069 loop {
1070 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1071 if remaining.is_zero() {
1072 break;
1073 }
1074 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1075 {
1076 Ok(s) => s,
1077 Err(_) => break,
1078 };
1079 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1080 || stats.files_indexed == 0
1081 {
1082 break;
1083 }
1084 }
1085 let _ = store.clear_warm_lock(&identity);
1086 ExitCode::SUCCESS
1087}
1088
1089fn now_secs() -> i64 {
1090 std::time::SystemTime::now()
1091 .duration_since(std::time::UNIX_EPOCH)
1092 .map(|d| d.as_secs() as i64)
1093 .unwrap_or(0)
1094}
1095
1096fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1099 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1100 let deadline = std::time::Instant::now() + live_fallback_budget();
1101 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1102 if !h.is_empty() {
1103 return h;
1104 }
1105 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1106}
1107
1108fn strong(h: &crate::search::Hit) -> bool {
1110 h.features
1111 .iter()
1112 .any(|f| matches!(f.name, "exact" | "prefix"))
1113}
1114
1115fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1124 if hits.iter().any(strong) {
1125 hits.retain(strong);
1126 }
1127 crate::search::apply_scope_gate(query, hits);
1128}
1129
1130fn apply_post_filters(
1133 args: &SearchArgs,
1134 cwd: Option<&std::path::Path>,
1135 root: Option<&std::path::Path>,
1136 hits: &mut Vec<crate::search::Hit>,
1137) {
1138 if !args.paths.is_empty() {
1139 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1143 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1144 let norm: Vec<String> = args
1145 .paths
1146 .iter()
1147 .map(|p| repo_relative(&base, &here, p))
1148 .collect();
1149 hits.retain(|h| under_any(&h.file, &norm));
1150 }
1151 if !args.kinds.is_empty() {
1152 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1153 }
1154 if !args.langs.is_empty() {
1155 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1156 }
1157 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1158 hits.truncate(args.want);
1159 }
1160}
1161
1162fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
1168 let status = if interrupted {
1169 "interrupted"
1170 } else if incomplete {
1171 "warming"
1172 } else {
1173 "no_match"
1174 };
1175 match out {
1176 Output::Json | Output::Ndjson => {
1177 let obj = serde_json::json!({ "status": status, "query": query });
1178 let _ = emit_json(out, &obj); }
1180 Output::Text if interrupted => {
1181 eprintln!("rq: indexing interrupted — run again to finish")
1182 }
1183 Output::Text if incomplete => eprintln!(
1184 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1185 ),
1186 Output::Text => eprintln!("no matches for {query:?}"),
1187 }
1188 if incomplete {
1189 ExitCode::from(2)
1190 } else {
1191 ExitCode::FAILURE
1192 }
1193}
1194
1195fn attach_confidence(hits: &mut [crate::search::Hit]) {
1199 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1200 if t.is_none_or(|t| h.score > t) {
1201 (Some(h.score), t)
1202 } else if s.is_none_or(|s| h.score > s) {
1203 (t, Some(h.score))
1204 } else {
1205 (t, s)
1206 }
1207 });
1208 for hit in hits.iter_mut() {
1209 let best_other = if Some(hit.score) == top { second } else { top };
1210 hit.confidence = crate::search::confidence(
1211 hit.score,
1212 crate::search::match_quality(&hit.features),
1213 best_other,
1214 );
1215 }
1216}
1217
1218fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1221 let render_span = crate::profile::span("render");
1225 if args.batch {
1226 #[derive(serde::Serialize)]
1229 struct Tagged<'a> {
1230 query: &'a str,
1231 #[serde(flatten)]
1232 hit: &'a crate::search::Hit,
1233 }
1234 let rows: Vec<Tagged> = hits
1235 .iter()
1236 .map(|hit| Tagged {
1237 query: args.query,
1238 hit,
1239 })
1240 .collect();
1241 if let Some(code) = emit_rows(args.out, &rows) {
1242 return Some(code);
1243 }
1244 } else if let Some(code) = emit_rows(args.out, hits) {
1245 return Some(code);
1246 }
1247 if args.out != Output::Text {
1248 return None;
1249 }
1250 drop(render_span);
1251 let color = match_color();
1252 let c = color.as_deref();
1253 let query = args.query;
1254 if args.show {
1255 eprintln!(
1257 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1258 hits.len()
1259 );
1260 }
1261 for hit in hits {
1262 let name = hl(&hit.name, query, c);
1265 let qualified = match &hit.parent {
1266 Some(p) => format!("{name} · {p}"),
1267 None => name,
1268 };
1269 println!(
1270 "{}:{} {} {}",
1271 hl_path(&hit.file, query, c),
1272 hit.line,
1273 hit.kind,
1274 qualified
1275 );
1276 if let Some(sig) = &hit.signature {
1277 println!(" {}", hl(sig, query, c));
1278 }
1279 if args.explain {
1280 let parts: Vec<String> = hit
1281 .features
1282 .iter()
1283 .map(|f| format!("{} {:.0}", f.name, f.value))
1284 .collect();
1285 println!(
1286 " confidence {:.2} · score {:.0} = {}",
1287 hit.confidence,
1288 hit.score,
1289 parts.join(" + ")
1290 );
1291 }
1292 }
1293 None
1294}
1295
1296fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1300 use std::io::{IsTerminal, Write};
1301 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1302 return hits.first();
1303 }
1304 let mut err = std::io::stderr();
1305 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1306 for (i, h) in hits.iter().enumerate() {
1307 let _ = writeln!(
1308 err,
1309 " {}. {}:{} {} {}",
1310 i + 1,
1311 h.file,
1312 h.line,
1313 h.kind,
1314 h.name
1315 );
1316 }
1317 let _ = write!(err, "rq> ");
1318 let _ = err.flush();
1319 let mut line = String::new();
1320 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1321 return None; }
1323 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1324}
1325
1326fn parse_choice(input: &str, n: usize) -> Option<usize> {
1329 let s = input.trim();
1330 if s.is_empty() {
1331 return Some(0);
1332 }
1333 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1334 (i < n).then_some(i)
1335}
1336
1337fn finish_open(
1341 store: &mut Store,
1342 hits: &[crate::search::Hit],
1343 query: &str,
1344 current: Option<i64>,
1345 root: Option<&std::path::Path>,
1346 no_record: bool,
1347) -> ExitCode {
1348 let Some(hit) = choose_hit(hits) else {
1349 return ExitCode::SUCCESS; };
1351
1352 if !no_record {
1355 let _ = store.record_event(
1356 "select",
1357 Some(&query.to_ascii_lowercase()),
1358 current,
1359 Some(&hit.file),
1360 Some(hit.line),
1361 None,
1362 );
1363 deferred_maintenance(store);
1364 }
1365
1366 let target = match root {
1369 Some(r) => r.join(&hit.file),
1370 None => PathBuf::from(&hit.file),
1371 };
1372 launch_editor(&target, hit.line)
1373}
1374
1375fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1379 use std::os::unix::process::CommandExt;
1380 let loc = format!("{}:{}", file.display(), line);
1381 match open_command(file, line, &loc) {
1382 Some((prog, args)) => {
1383 let err = std::process::Command::new(&prog).args(&args).exec();
1385 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1386 }
1387 None => {
1388 println!("{loc}");
1389 ExitCode::SUCCESS
1390 }
1391 }
1392}
1393
1394fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1398 let fstr = file.to_string_lossy().into_owned();
1399
1400 if let Some(t) = std::env::var_os("RQ_OPEN") {
1401 let t = t.to_string_lossy();
1402 let mut parts = t.split_whitespace().map(|p| {
1403 p.replace("{file}", &fstr)
1404 .replace("{line}", &line.to_string())
1405 .replace("{}", loc)
1406 });
1407 if let Some(prog) = parts.next() {
1408 return Some((prog, parts.collect()));
1409 }
1410 }
1411
1412 if on_path("code") {
1413 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1414 }
1415
1416 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1417 let ed = ed.to_string_lossy().into_owned();
1418 let l = ed.to_ascii_lowercase();
1419 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1421 .iter()
1422 .any(|e| l.contains(e))
1423 {
1424 return Some((ed, vec![format!("+{line}"), fstr]));
1425 }
1426 return Some((ed, vec![fstr]));
1427 }
1428
1429 None
1430}
1431
1432fn on_path(prog: &str) -> bool {
1434 std::env::var_os("PATH")
1435 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1436}
1437
1438fn unix_now() -> i64 {
1448 std::time::SystemTime::now()
1449 .duration_since(std::time::UNIX_EPOCH)
1450 .map(|d| d.as_secs() as i64)
1451 .unwrap_or(0)
1452}
1453
1454const BRANCH_FILES_TTL_SECS: i64 = 15;
1460
1461struct BranchRefresh {
1465 handle: std::thread::JoinHandle<Vec<String>>,
1466 identity: String,
1467 stamp: String,
1468}
1469
1470impl BranchRefresh {
1471 fn store(self, store: &Store) {
1473 let Ok(files) = self.handle.join() else {
1474 return;
1475 };
1476 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1477 }
1478}
1479
1480fn cached_branch_files(
1494 store: &Store,
1495 root: &std::path::Path,
1496) -> (Vec<String>, Option<BranchRefresh>) {
1497 let identity = resolve_identity(store, root);
1498 let stamp = crate::index::branch_files_stamp(root);
1499 let cached = store.branch_files_get(&identity).ok().flatten();
1500 let now = unix_now();
1501
1502 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1503 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1504 return (files.clone(), None);
1505 }
1506 let owned_root = root.to_path_buf();
1507 let refresh = BranchRefresh {
1508 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1509 identity,
1510 stamp: stamp.clone(),
1511 };
1512 return (files.clone(), Some(refresh));
1513 }
1514
1515 let files = crate::index::branch_changed_files(root);
1517 if let Some(stamp) = stamp {
1518 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1519 }
1520 (files, None)
1521}
1522
1523fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1532 let Some(head) = indexed_head else {
1533 return true;
1534 };
1535 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1536}
1537
1538#[allow(clippy::too_many_arguments)]
1546fn settle_warm(
1547 store: &Store,
1548 staleness: Option<std::thread::JoinHandle<bool>>,
1549 was_warming: bool,
1550 warming_ok: bool,
1551 root: Option<&std::path::Path>,
1552 active: &[String],
1553 query: &str,
1554 budget: Duration,
1555 no_wait: bool,
1556 identity: Option<&str>,
1557) -> bool {
1558 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1561 if changed
1571 && !no_wait
1572 && !warm_detach_enabled()
1573 && let Some(r) = root
1574 && let Ok(mut idx) = open_store()
1575 {
1576 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1577 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1578 }
1579 maybe_detach_warm(
1580 store,
1581 warming_ok && (was_warming || changed),
1582 changed,
1583 root,
1584 identity,
1585 );
1586 changed && warm_detach_enabled()
1590}
1591
1592fn answer_warm_budget() -> Duration {
1601 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1602}
1603
1604fn deferred_warm_budget() -> Duration {
1607 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1608}
1609
1610fn live_fallback_budget() -> Duration {
1613 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1614}
1615
1616fn warm_bg_budget() -> Duration {
1619 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1620}
1621
1622fn warm_detach_enabled() -> bool {
1626 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1627}
1628
1629fn wait_budget() -> Duration {
1638 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1639}
1640
1641fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1646 let s = s.trim();
1647 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1648 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1650 (n, 1.0)
1651 } else if let Some(n) = s.strip_suffix('s') {
1652 (n, 1_000.0)
1653 } else if let Some(n) = s.strip_suffix('m') {
1654 (n, 60_000.0)
1655 } else {
1656 (s, 1_000.0)
1658 };
1659 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1660 if !val.is_finite() || val < 0.0 {
1661 return Err(bad());
1662 }
1663 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1664}
1665
1666static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1670
1671extern "C" fn on_sigint(_: libc::c_int) {
1672 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1674}
1675
1676fn install_interrupt_handler() {
1679 static ONCE: std::sync::Once = std::sync::Once::new();
1680 ONCE.call_once(|| unsafe {
1681 let mut action: libc::sigaction = std::mem::zeroed();
1682 action.sa_sigaction = on_sigint as *const () as usize;
1683 libc::sigemptyset(&mut action.sa_mask);
1684 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1685 });
1686}
1687
1688fn stderr_interactive() -> bool {
1692 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1693}
1694
1695fn show_progress(out: Output, interactive: bool) -> bool {
1700 interactive && matches!(out, Output::Text)
1701}
1702
1703fn repo_label(root: Option<&std::path::Path>) -> String {
1706 root.and_then(|r| r.file_name())
1707 .map(|n| n.to_string_lossy().into_owned())
1708 .unwrap_or_else(|| "repo".into())
1709}
1710
1711fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1715 let files = identity
1716 .and_then(|id| store.repository_id(id).ok().flatten())
1717 .and_then(|rid| store.repo_totals(rid).ok())
1718 .map_or(0, |(f, _)| f);
1719 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1720 let _ = std::io::stderr().flush();
1721}
1722
1723fn clear_progress() {
1725 eprint!("\r\x1b[K");
1726 let _ = std::io::stderr().flush();
1727}
1728
1729fn env_budget(var: &str, default_ms: u64) -> Duration {
1733 let ms = std::env::var(var)
1734 .ok()
1735 .and_then(|v| v.parse().ok())
1736 .unwrap_or(default_ms);
1737 Duration::from_millis(ms)
1738}
1739
1740const AGGREGATE_BATCH: usize = 256;
1743
1744const KEEP_RECENT_EVENTS: i64 = 200;
1747
1748fn deferred_maintenance(store: &mut Store) {
1751 let _ = store.aggregate_events(AGGREGATE_BATCH);
1752 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1753}
1754
1755fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1758 let mut store = match open_store() {
1759 Ok(s) => s,
1760 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1761 };
1762 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1763 let identity = crate::index::detect_identity(&cwd).to_string();
1764 let repo_id = store.repository_id(&identity).ok().flatten();
1765
1766 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1769 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1770 None => file.to_string(),
1771 };
1772 let query_norm = query.map(|q| q.to_ascii_lowercase());
1773
1774 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1775 {
1776 return fail(format_args!("rq record: {e}"));
1777 }
1778 deferred_maintenance(&mut store);
1779 ExitCode::SUCCESS
1780}
1781
1782fn hit_file_roots(
1788 store: &Store,
1789 repo_identity: &str,
1790 cwd: Option<&std::path::Path>,
1791) -> Vec<PathBuf> {
1792 let mut roots: Vec<PathBuf> = store
1793 .repository_id(repo_identity)
1794 .ok()
1795 .flatten()
1796 .map(|id| store.checkout_roots(id).unwrap_or_default())
1797 .unwrap_or_default()
1798 .into_iter()
1799 .map(PathBuf::from)
1800 .collect();
1801 if let Some(c) = cwd {
1802 let c = c.to_path_buf();
1803 if !roots.contains(&c) {
1804 roots.push(c);
1805 }
1806 }
1807 roots
1808}
1809
1810fn read_signature(
1813 store: &Store,
1814 repo_identity: &str,
1815 file: &str,
1816 line: i64,
1817 cwd: Option<&std::path::Path>,
1818) -> Option<String> {
1819 hit_file_roots(store, repo_identity, cwd)
1820 .into_iter()
1821 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1822}
1823
1824const SHOW_CONFIDENCE: f64 = 0.85;
1828
1829fn show_top_definition(
1833 store: &Store,
1834 hits: &mut [crate::search::Hit],
1835 query: &str,
1836 out: Output,
1837 cwd: Option<&std::path::Path>,
1838) -> Option<ExitCode> {
1839 let top = hits.first()?;
1840 if top.confidence < SHOW_CONFIDENCE {
1841 return None; }
1843 let end = top.end_line.unwrap_or(top.line);
1844 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1845 hits[0].body = body;
1846 let top = &hits[0];
1847 match out {
1848 Output::Json | Output::Ndjson => {
1849 return Some(emit_json(out, top));
1851 }
1852 Output::Text => {
1853 let color = match_color();
1854 let c = color.as_deref();
1855 let name = hl(&top.name, query, c);
1856 let qualified = match &top.parent {
1857 Some(p) => format!("{name} · {p}"),
1858 None => name,
1859 };
1860 println!(
1861 "{}:{} {} {}",
1862 hl_path(&top.file, query, c),
1863 top.line,
1864 top.kind,
1865 qualified
1866 );
1867 match (&top.body, &top.signature) {
1868 (Some(body), _) => println!("{body}"),
1869 (None, Some(sig)) => println!("{sig}"),
1871 (None, None) => {}
1872 }
1873 }
1874 }
1875 Some(ExitCode::SUCCESS)
1876}
1877
1878fn read_span(
1881 store: &Store,
1882 repo_identity: &str,
1883 file: &str,
1884 start: i64,
1885 end: i64,
1886 cwd: Option<&std::path::Path>,
1887) -> Option<String> {
1888 hit_file_roots(store, repo_identity, cwd)
1889 .into_iter()
1890 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1891}
1892
1893fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1896 let s = usize::try_from(start).ok()?.checked_sub(1)?;
1897 let lines: Vec<&str> = content.lines().collect();
1898 if s >= lines.len() {
1899 return None;
1900 }
1901 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1902 Some(lines[s..e].join("\n"))
1903}
1904
1905fn signature_in(content: &str, line: i64) -> Option<String> {
1909 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1910 let l = content.lines().nth(idx)?.trim();
1911 (!l.is_empty()).then(|| l.to_string())
1912}
1913
1914#[derive(serde::Serialize)]
1918struct SymbolOut {
1919 name: String,
1920 kind: String,
1921 language: String,
1922 file: String,
1923 line: i64,
1924 #[serde(skip_serializing_if = "Option::is_none")]
1925 end_line: Option<i64>,
1926 #[serde(skip_serializing_if = "Option::is_none")]
1927 parent: Option<String>,
1928 #[serde(skip_serializing_if = "Option::is_none")]
1929 visibility: Option<String>,
1930 repo: String,
1931 #[serde(skip_serializing_if = "Option::is_none")]
1932 signature: Option<String>,
1933}
1934
1935fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
1940 let mut store = match open_store() {
1941 Ok(s) => s,
1942 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1943 };
1944 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1945 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
1946 let rel = repo_relative(&root, &cwd, file_arg);
1947
1948 let identity = resolve_identity(&store, &root);
1949 let coverage = store.coverage_status(&identity).ok().flatten();
1950 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
1951 let current = store.repository_id(&identity).ok().flatten();
1952 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
1955 let needs_warm = warming_ok
1956 && (coverage.as_deref() != Some("complete")
1957 || worktree_changed(&root, indexed_head.as_deref()));
1958 if needs_warm {
1959 let budget = answer_warm_budget() + deferred_warm_budget();
1961 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
1962 }
1963
1964 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
1965 return emit_symbols(out, &[]); };
1967 let mut rows = match store.symbols_in_file(repo_id, &rel) {
1968 Ok(r) => r,
1969 Err(e) => return fail(format_args!("rq: {e}")),
1970 };
1971 if !kinds.is_empty() {
1972 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
1973 }
1974 if !langs.is_empty() {
1975 rows.retain(|r| langs.iter().any(|l| l == &r.language));
1976 }
1977
1978 let content = hit_file_roots(&store, &identity, Some(&root))
1982 .iter()
1983 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
1984 let syms: Vec<SymbolOut> = rows
1985 .into_iter()
1986 .map(|r| SymbolOut {
1987 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
1988 name: r.name,
1989 kind: r.kind,
1990 language: r.language,
1991 file: r.file,
1992 line: r.line,
1993 end_line: r.end_line,
1994 parent: r.parent,
1995 visibility: r.visibility,
1996 repo: r.repo_identity,
1997 })
1998 .collect();
1999 emit_symbols(out, &syms)
2000}
2001
2002fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2005 if syms.is_empty() {
2006 match out {
2007 Output::Json | Output::Ndjson => {
2008 let obj = serde_json::json!({ "status": "no_match" });
2009 let _ = emit_json(out, &obj); }
2011 Output::Text => eprintln!("no symbols"),
2012 }
2013 return ExitCode::FAILURE;
2014 }
2015 if let Some(code) = emit_rows(out, syms) {
2016 return code;
2017 }
2018 match out {
2019 Output::Json | Output::Ndjson => {}
2020 Output::Text => {
2021 for s in syms {
2022 let qualified = match &s.parent {
2023 Some(p) => format!("{} · {p}", s.name),
2024 None => s.name.clone(),
2025 };
2026 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2027 if let Some(sig) = &s.signature {
2028 println!(" {sig}");
2029 }
2030 }
2031 }
2032 }
2033 ExitCode::SUCCESS
2034}
2035
2036fn keyword_kind(token: &str) -> Option<&'static str> {
2041 match token.to_ascii_lowercase().as_str() {
2042 "class" => Some("class"),
2043 "module" => Some("module"),
2044 "method" => Some("method"),
2045 "function" | "fn" => Some("function"),
2046 "struct" | "type" => Some("struct"),
2047 "enum" => Some("enum"),
2048 "trait" | "interface" => Some("trait"),
2049 _ => None,
2050 }
2051}
2052
2053fn split_kind_keyword(
2059 target: String,
2060 dirs: Vec<String>,
2061) -> (Option<&'static str>, String, Vec<String>) {
2062 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2065 let rest = rest.trim();
2066 if let Some(k) = keyword_kind(head)
2067 && !rest.is_empty()
2068 {
2069 return (Some(k), rest.to_string(), dirs);
2070 }
2071 } else if let Some(k) = keyword_kind(&target)
2072 && let Some((query, extra)) = dirs.split_first()
2073 {
2074 return (Some(k), query.clone(), extra.to_vec());
2076 }
2077 (None, target, dirs)
2078}
2079
2080fn canonical_kind(s: &str) -> String {
2083 match s.to_ascii_lowercase().as_str() {
2084 "c" | "class" => "class",
2085 "m" | "method" => "method",
2086 "f" | "fn" | "func" | "function" => "function",
2087 "mod" | "module" => "module",
2088 "s" | "struct" | "type" => "struct",
2089 "e" | "enum" => "enum",
2090 "t" | "trait" | "interface" => "trait",
2091 other => return other.to_string(),
2092 }
2093 .to_string()
2094}
2095
2096fn canonical_langs(s: &str) -> Vec<String> {
2103 let t = s.to_ascii_lowercase();
2104 let alias = match t.as_str() {
2105 "rb" => Some("ruby"),
2106 "rs" => Some("rust"),
2107 "golang" => Some("go"),
2108 "ts" | "tsx" => Some("typescript"),
2109 "js" | "jsx" => Some("javascript"),
2110 _ => None,
2111 };
2112 let matched: Vec<String> = crate::lang::languages()
2113 .into_iter()
2114 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2115 .map(str::to_string)
2116 .collect();
2117 if matched.is_empty() { vec![t] } else { matched }
2118}
2119
2120fn match_color() -> Option<String> {
2124 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2125 return None;
2126 }
2127 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2128 gc.split(':').find_map(|e| {
2129 e.strip_prefix("mt=")
2130 .or_else(|| e.strip_prefix("ms="))
2131 .filter(|v| !v.is_empty())
2132 .map(str::to_string)
2133 })
2134 });
2135 Some(style.unwrap_or_else(|| "1;31".to_string()))
2136}
2137
2138fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2141 match color {
2142 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2143 None => text.to_string(),
2144 }
2145}
2146
2147fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2150 let Some(c) = color else {
2151 return path.to_string();
2152 };
2153 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2154 let base_start = path[..base_byte].chars().count();
2155 let stem = crate::search::path_stem(path);
2159 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2160 .into_iter()
2161 .map(|p| p + base_start)
2162 .collect();
2163 highlight(path, &positions, c)
2164}
2165
2166fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2169 if positions.is_empty() {
2170 return text.to_string();
2171 }
2172 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2173 let mut out = String::new();
2174 let mut on = false;
2175 for (i, c) in text.chars().enumerate() {
2176 match (matched.contains(&i), on) {
2177 (true, false) => {
2178 out.push_str("\x1b[");
2179 out.push_str(color);
2180 out.push('m');
2181 on = true;
2182 }
2183 (false, true) => {
2184 out.push_str("\x1b[0m");
2185 on = false;
2186 }
2187 _ => {}
2188 }
2189 out.push(c);
2190 }
2191 if on {
2192 out.push_str("\x1b[0m");
2193 }
2194 out
2195}
2196
2197fn under_any(file: &str, paths: &[String]) -> bool {
2201 paths.iter().any(|p| {
2202 let p = p.trim_start_matches("./").trim_end_matches('/');
2203 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2204 })
2205}
2206
2207fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2209 let p = std::path::Path::new(file);
2210 let abs = if p.is_absolute() {
2211 p.to_path_buf()
2212 } else {
2213 cwd.join(p)
2214 };
2215 let abs = abs.canonicalize().unwrap_or(abs);
2216 abs.strip_prefix(root)
2217 .map(|r| r.to_string_lossy().into_owned())
2218 .unwrap_or_else(|_| file.to_string())
2219}
2220
2221fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2225 use std::collections::HashSet;
2226 let mut seen = HashSet::new();
2227 let mut changed = false;
2228 for hit in hits {
2229 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2230 continue;
2231 }
2232 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2233 continue;
2234 };
2235 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2236 continue;
2237 };
2238 if let Ok(crate::index::Refresh::Updated) =
2239 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2240 {
2241 changed = true;
2242 }
2243 }
2244 changed
2245}
2246
2247fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2253 if let Ok(canon) = cwd.canonicalize() {
2254 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2255 return identity;
2256 }
2257 if crate::index::repo_root(cwd).is_none() {
2258 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2259 }
2260 }
2261 crate::index::detect_identity(cwd).to_string()
2262}
2263
2264fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2265 let explicit = path.is_some();
2266 let target = path.unwrap_or_else(|| PathBuf::from("."));
2267 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2272 let mut subdirs = subdirs.to_vec();
2277 if explicit
2278 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2279 && t != r
2280 && let Ok(rel) = t.strip_prefix(&r)
2281 && !rel.as_os_str().is_empty()
2282 {
2283 subdirs.push(rel.to_string_lossy().into_owned());
2284 }
2285 let mut store = match open_store() {
2286 Ok(s) => s,
2287 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2288 };
2289 let identity = crate::index::detect_identity(&root).to_string();
2290 match crate::index::index_under(&mut store, &root, &subdirs) {
2291 Ok(stats) => {
2292 let subtree = !subdirs.is_empty();
2293 let totals = store
2295 .repository_id(&identity)
2296 .ok()
2297 .flatten()
2298 .and_then(|id| store.repo_totals(id).ok());
2299 match out {
2300 Output::Json | Output::Ndjson => {
2301 let (files, symbols) = match totals {
2302 Some((f, s)) => (Some(f), Some(s)),
2303 None => (None, None),
2304 };
2305 return emit_json(
2306 out,
2307 &serde_json::json!({
2308 "repo": identity,
2309 "scope": if subtree { "subtree" } else { "full" },
2310 "files_added": stats.files_indexed,
2311 "symbols_added": stats.symbols,
2312 "files": files,
2313 "symbols": symbols,
2314 }),
2315 );
2316 }
2317 Output::Text => {
2318 let scope = if subtree { " (subtree seed)" } else { "" };
2319 match totals {
2320 Some((files, symbols)) => println!(
2321 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2322 stats.files_indexed, stats.symbols
2323 ),
2324 None => println!(
2325 "{} file(s)/{} symbol(s) added this run{scope}",
2326 stats.files_indexed, stats.symbols
2327 ),
2328 }
2329 }
2330 }
2331 ExitCode::SUCCESS
2332 }
2333 Err(e) => fail(format_args!("rq --index: {e}")),
2334 }
2335}
2336
2337fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2338 let mut store = match open_store() {
2339 Ok(s) => s,
2340 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2341 };
2342
2343 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2347 let root = crate::index::repo_root(&path).unwrap_or(path);
2348 let from_path = crate::index::detect_identity(&root).to_string();
2349 let resolved = match store.repository_id(&from_path) {
2350 Ok(Some(id)) => Some((from_path.clone(), id)),
2351 Ok(None) => target.as_deref().and_then(|s| {
2352 store
2353 .repository_id(s)
2354 .ok()
2355 .flatten()
2356 .map(|id| (s.to_string(), id))
2357 }),
2358 Err(e) => return fail(format_args!("rq --drop: {e}")),
2359 };
2360
2361 let Some((identity, repo_id)) = resolved else {
2362 return match out {
2364 Output::Text => {
2365 println!("not indexed: {from_path}");
2366 ExitCode::SUCCESS
2367 }
2368 _ => emit_json(
2369 out,
2370 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2371 ),
2372 };
2373 };
2374
2375 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2376 match store.drop_repository(repo_id) {
2377 Ok(()) => match out {
2378 Output::Text => {
2379 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2380 ExitCode::SUCCESS
2381 }
2382 _ => emit_json(
2383 out,
2384 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2385 ),
2386 },
2387 Err(e) => fail(format_args!("rq --drop: {e}")),
2388 }
2389}
2390
2391fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2395 let rendered = if out == Output::Json {
2396 serde_json::to_string_pretty(value)
2397 } else {
2398 serde_json::to_string(value)
2399 };
2400 match rendered {
2401 Ok(s) => {
2402 println!("{s}");
2403 ExitCode::SUCCESS
2404 }
2405 Err(e) => fail(format_args!("rq: {e}")),
2406 }
2407}
2408
2409fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2413 match out {
2414 Output::Json => match serde_json::to_string_pretty(rows) {
2415 Ok(s) => println!("{s}"),
2416 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2417 },
2418 Output::Ndjson => {
2419 for r in rows {
2420 match serde_json::to_string(r) {
2421 Ok(line) => println!("{line}"),
2422 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2423 }
2424 }
2425 }
2426 Output::Text => {}
2427 }
2428 None
2429}
2430
2431fn cmd_status(out: Output) -> ExitCode {
2432 let store = match open_store() {
2433 Ok(s) => s,
2434 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2435 };
2436 let rows = match store.coverage_overview() {
2437 Ok(rows) => rows,
2438 Err(e) => return fail(format_args!("rq --status: {e}")),
2439 };
2440 if let Some(code) = emit_rows(out, &rows) {
2441 return code;
2442 }
2443 match out {
2444 Output::Json | Output::Ndjson => {}
2445 Output::Text if rows.is_empty() => {
2446 println!("no repositories indexed yet (try `rq --index`)");
2447 }
2448 Output::Text => {
2449 for r in &rows {
2450 println!(
2451 "{:<10} {:>6} files {:>7} symbols {}",
2452 r.status, r.files, r.symbols, r.identity
2453 );
2454 }
2455 }
2456 }
2457 ExitCode::SUCCESS
2458}
2459
2460fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2462 let path = db_path()?;
2463 if let Some(parent) = path.parent() {
2464 std::fs::create_dir_all(parent)?;
2465 }
2466 Ok(Store::open(&path)?)
2467}
2468
2469fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2471 if let Ok(p) = std::env::var("RQ_DB") {
2472 return Ok(PathBuf::from(p));
2473 }
2474 let home = std::env::var("HOME")?;
2475 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2476}
2477
2478fn fail(args: std::fmt::Arguments) -> ExitCode {
2479 eprintln!("{args}");
2480 ExitCode::FAILURE
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485 use super::*;
2486
2487 #[test]
2488 fn open_menu_choice_parsing() {
2489 assert_eq!(parse_choice("\n", 5), Some(0));
2491 assert_eq!(parse_choice(" ", 5), Some(0));
2492 assert_eq!(parse_choice("3", 5), Some(2));
2493 assert_eq!(parse_choice("5", 5), Some(4));
2494 assert_eq!(parse_choice("6", 5), None);
2496 assert_eq!(parse_choice("0", 5), None);
2497 assert_eq!(parse_choice("q", 5), None);
2498 }
2499
2500 #[test]
2501 fn wait_duration_parsing() {
2502 use std::time::Duration;
2503 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2505 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2506 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2507 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2508 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2510 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2511 assert!(parse_wait("0s").unwrap().is_zero());
2512 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2514 assert!(parse_wait("2x").is_err());
2516 assert!(parse_wait("").is_err());
2517 assert!(parse_wait("s").is_err());
2518 assert!(parse_wait("-1s").is_err());
2519 }
2520
2521 #[test]
2522 fn leading_kind_keyword_becomes_a_kind_filter() {
2523 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2524 assert_eq!(
2526 split_kind_keyword("class".into(), d(&["Widget"])),
2527 (Some("class"), "Widget".into(), vec![])
2528 );
2529 assert_eq!(
2531 split_kind_keyword("method zoom".into(), vec![]),
2532 (Some("method"), "zoom".into(), vec![])
2533 );
2534 assert_eq!(
2536 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2537 (Some("function"), "Foo::run".into(), vec![])
2538 );
2539 assert_eq!(
2541 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2542 (Some("struct"), "Gadget".into(), d(&["src"]))
2543 );
2544 }
2545
2546 #[test]
2547 fn a_bare_or_non_keyword_query_is_left_alone() {
2548 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2549 assert_eq!(
2551 split_kind_keyword("class".into(), vec![]),
2552 (None, "class".into(), vec![])
2553 );
2554 assert_eq!(
2556 split_kind_keyword("Widget".into(), d(&["app"])),
2557 (None, "Widget".into(), d(&["app"]))
2558 );
2559 assert_eq!(
2561 split_kind_keyword("c".into(), d(&["Foo"])),
2562 (None, "c".into(), d(&["Foo"]))
2563 );
2564 }
2565
2566 #[test]
2567 fn a_language_selects_by_prefix_or_alias() {
2568 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2570 assert_eq!(canonical_langs("t"), ["typescript"]);
2571 assert_eq!(canonical_langs("ts"), ["typescript"]);
2573 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2574 assert_eq!(canonical_langs("rb"), ["ruby"]);
2575 assert_eq!(canonical_langs("COBOL"), ["cobol"]);
2577 }
2578
2579 #[test]
2580 fn a_kind_normalizes_language_specific_spellings() {
2581 assert_eq!(canonical_kind("f"), "function");
2582 assert_eq!(canonical_kind("interface"), "trait");
2584 assert_eq!(canonical_kind("type"), "struct");
2585 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2587 assert_eq!(
2588 split_kind_keyword("interface".into(), d(&["Renderer"])),
2589 (Some("trait"), "Renderer".into(), vec![])
2590 );
2591 }
2592
2593 #[test]
2594 fn highlight_wraps_matched_runs() {
2595 assert_eq!(
2596 highlight("FooThing", &[0, 1, 2], "1;31"),
2597 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2598 );
2599 assert_eq!(
2601 highlight("FooThing", &[0, 3], "1"),
2602 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2603 );
2604 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2606 }
2607
2608 #[test]
2609 fn progress_ui_only_for_an_interactive_text_terminal() {
2610 assert!(show_progress(Output::Text, true));
2612
2613 assert!(!show_progress(Output::Json, true));
2615 assert!(!show_progress(Output::Ndjson, true));
2616
2617 assert!(!show_progress(Output::Text, false));
2619 }
2620
2621 #[test]
2622 fn repo_label_uses_the_directory_name() {
2623 assert_eq!(
2624 repo_label(Some(std::path::Path::new("/src/widgets"))),
2625 "widgets"
2626 );
2627 assert_eq!(repo_label(None), "repo");
2628 }
2629
2630 #[test]
2631 fn hl_path_highlights_the_stem_not_the_extension() {
2632 let out = hl_path(
2635 "app/employees_controller.rb",
2636 "employeescontroller",
2637 Some("1;31"),
2638 );
2639 assert!(
2640 out.starts_with("app/\u{1b}[1;31memployees"),
2641 "stem highlighted: {out:?}"
2642 );
2643 assert!(
2644 out.ends_with("controller\u{1b}[0m.rb"),
2645 "`.rb` left un-highlighted: {out:?}"
2646 );
2647 }
2648}