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)]
78 no_record: bool,
79
80 #[arg(long = "no-wait")]
86 no_wait: bool,
87
88 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
93 wait: Option<Duration>,
94
95 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
100 open: bool,
101
102 #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
106 show: bool,
107
108 #[arg(short = 'j', long)]
110 json: bool,
111
112 #[arg(short = 'J', long, conflicts_with = "json")]
114 ndjson: bool,
115
116 #[arg(short = 'p', long, value_name = "DIR")]
118 path: Vec<String>,
119
120 #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
122 limit: usize,
123
124 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
128 kind: Vec<String>,
129
130 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
134 lang: Vec<String>,
135
136 #[arg(short = 'a', long = "all-repos")]
139 all_repos: bool,
140
141 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
143 index: Option<Option<String>>,
144
145 #[arg(long, conflicts_with_all = ["index", "record"])]
147 status: bool,
148
149 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
152 symbols: Option<String>,
153
154 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
158 drop: bool,
159
160 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
163 record: bool,
164
165 #[arg(long)]
167 file: Option<String>,
168
169 #[arg(long)]
171 line: Option<i64>,
172
173 #[arg(long, default_value = "select")]
175 event: String,
176
177 #[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"])]
181 warm: Option<Option<String>>,
182
183 #[arg(long, value_name = "SHELL")]
185 completions: Option<Shell>,
186
187 #[arg(short = 'v', long)]
190 verbose: bool,
191
192 #[arg(long)]
196 profile: bool,
197
198 #[arg(long, value_name = "N", default_value_t = 0)]
201 jobs: usize,
202}
203
204pub fn run() -> ExitCode {
206 let cli = Cli::parse();
207 crate::trace::enable_from(cli.verbose);
208 crate::profile::enable_from(cli.profile);
209 crate::index::set_parse_jobs(cli.jobs);
210
211 if let Some(shell) = cli.completions {
212 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
213 return ExitCode::SUCCESS;
214 }
215 if let Some(path) = &cli.index {
216 let out = output_format(&cli);
218 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
219 }
220 if let Some(path) = &cli.warm {
221 return cmd_warm(path.as_deref());
222 }
223 if cli.status {
224 return cmd_status(output_format(&cli));
225 }
226 if cli.drop {
227 let out = output_format(&cli);
228 return cmd_drop(cli.target, out);
229 }
230 if cli.record {
231 if !matches!(cli.event.as_str(), "select" | "open") {
233 return fail(format_args!(
234 "rq --record: unknown --event {:?} (expected select or open)",
235 cli.event
236 ));
237 }
238 let file = cli.file.expect("--record requires --file");
240 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
241 }
242 let out = output_format(&cli);
243 let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
244 let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
246 if let Some(file) = &cli.symbols {
247 return cmd_symbols(file, &kinds, &langs, out);
248 }
249 let mut paths = cli.path.clone();
251 match cli.target {
252 Some(target) => {
253 let query = if cli.kind.is_empty() {
256 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
257 if let Some(k) = kw {
258 kinds.push(k.to_string());
259 }
260 paths.extend(dirs);
261 query
262 } else {
263 paths.extend(cli.dirs.clone());
264 target
265 };
266 let mut session = match Session::open() {
267 Ok(s) => s,
268 Err(code) => return code,
269 };
270 cmd_search(
271 &mut session,
272 &SearchArgs {
273 query: &query,
274 explain: cli.explain,
275 out,
276 paths: &paths,
277 kinds: &kinds,
278 langs: &langs,
279 want: requested_limit(cli.limit),
280 no_record: cli.no_record,
281 no_wait: cli.no_wait,
282 wait: cli.wait,
283 open: cli.open,
284 all_repos: cli.all_repos,
285 show: cli.show,
286 batch: false,
287 },
288 )
289 }
290 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
293 None => {
295 let _ = Cli::command().print_long_help();
296 ExitCode::SUCCESS
297 }
298 }
299}
300
301#[derive(Clone, Copy, PartialEq)]
303enum Output {
304 Text,
305 Json,
306 Ndjson,
307}
308
309fn output_format(cli: &Cli) -> Output {
310 if cli.ndjson {
311 Output::Ndjson
312 } else if cli.json {
313 Output::Json
314 } else {
315 Output::Text
316 }
317}
318
319const PATH_HEADROOM: usize = 200;
322
323fn requested_limit(limit: usize) -> usize {
326 if limit == 0 { usize::MAX } else { limit }
327}
328
329const POLL_INTERVAL: Duration = Duration::from_millis(100);
336
337const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
341
342const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
346
347struct SearchArgs<'a> {
349 query: &'a str,
350 explain: bool,
351 out: Output,
352 paths: &'a [String],
353 kinds: &'a [String],
354 langs: &'a [String],
355 want: usize,
357 no_record: bool,
358 no_wait: bool,
360 wait: Option<Duration>,
363 open: bool,
364 all_repos: bool,
365 batch: bool,
369 show: bool,
370}
371
372struct Session {
383 store: Store,
384 cwd: Option<PathBuf>,
385 cwd_is_git: bool,
386 root: Option<PathBuf>,
387 active_paths: Vec<String>,
388 branch_refresh: Option<BranchRefresh>,
389 identity: Option<String>,
390 coverage: Option<String>,
391}
392
393impl Session {
394 fn open() -> std::result::Result<Session, ExitCode> {
396 let open_span = crate::profile::span("store open");
397 let store = match open_store() {
398 Ok(s) => s,
399 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
400 };
401 drop(open_span);
402 let git_span = crate::profile::span("setup: git root");
403 let cwd = std::env::current_dir().ok();
404 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
405
406 let root = cwd
412 .as_deref()
413 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
414 drop(git_span);
415
416 let mut branch_span = crate::profile::span("setup: branch files");
419 let (active_paths, branch_refresh) = match &root {
420 Some(c) if cwd_is_git => cached_branch_files(&store, c),
421 _ => (Vec::new(), None),
422 };
423 branch_span.note(|| {
424 let how = if branch_refresh.is_some() {
425 "cached, refreshing alongside"
426 } else {
427 "cached"
428 };
429 format!("{} changed, {how}", active_paths.len())
430 });
431 drop(branch_span);
432
433 let mut identity_span = crate::profile::span("setup: identity");
438 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
439 let coverage = identity
440 .as_deref()
441 .and_then(|id| store.coverage_status(id).ok())
442 .flatten();
443 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
444 drop(identity_span);
445 Ok(Session {
446 store,
447 cwd,
448 cwd_is_git,
449 root,
450 active_paths,
451 branch_refresh,
452 identity,
453 coverage,
454 })
455 }
456}
457
458fn cmd_batch(
473 cli: &Cli,
474 out: Output,
475 paths: &[String],
476 kinds: &[String],
477 langs: &[String],
478) -> ExitCode {
479 if out == Output::Json {
480 return fail(format_args!(
481 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
482 where each line carries the query it answers"
483 ));
484 }
485 if cli.open || cli.show {
486 return fail(format_args!(
487 "rq: --open and --show act on a single result, not a stream of queries"
488 ));
489 }
490
491 use std::io::BufRead;
492 let queries: Vec<String> = std::io::stdin()
493 .lock()
494 .lines()
495 .map_while(std::result::Result::ok)
496 .map(|l| l.trim().to_string())
497 .filter(|l| !l.is_empty())
498 .collect();
499 if queries.is_empty() {
503 let _ = Cli::command().print_long_help();
504 return ExitCode::SUCCESS;
505 }
506
507 let mut session = match Session::open() {
508 Ok(s) => s,
509 Err(code) => return code,
510 };
511
512 if !cli.no_wait
515 && session.coverage.as_deref() != Some("complete")
516 && let Some(root) = session.root.clone()
517 {
518 {
519 let budget = cli.wait.unwrap_or_else(wait_budget);
520 crate::trace!(
521 "batch: warming {} queries' worth of index first",
522 queries.len()
523 );
524 let active = session.active_paths.clone();
525 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
526 session.coverage = session
527 .identity
528 .as_deref()
529 .and_then(|id| session.store.coverage_status(id).ok())
530 .flatten();
531 }
532 }
533
534 let mut worst = ExitCode::SUCCESS;
535 let mut any_hit = false;
536 for query in &queries {
537 let code = cmd_search(
538 &mut session,
539 &SearchArgs {
540 query,
541 explain: cli.explain,
542 out,
543 paths,
544 kinds,
545 langs,
546 want: requested_limit(cli.limit),
547 no_record: cli.no_record,
548 no_wait: true,
552 wait: cli.wait,
553 open: false,
554 all_repos: cli.all_repos,
555 show: false,
556 batch: true,
557 },
558 );
559 if code == ExitCode::SUCCESS {
560 any_hit = true;
561 } else {
562 worst = code;
563 }
564 }
565 if any_hit { ExitCode::SUCCESS } else { worst }
568}
569
570fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
571 let &SearchArgs {
572 query,
573 out,
574 want,
575 no_record,
576 no_wait,
577 wait,
578 open,
579 all_repos,
580 show,
581 ..
582 } = args;
583 let wait_budget = wait.unwrap_or_else(wait_budget);
586 let no_wait = no_wait || wait_budget.is_zero();
587 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
590 want
591 } else {
592 want.saturating_mul(20).max(PATH_HEADROOM)
593 };
594 let _timer = crate::trace::Timer::start("search done");
595 let profile_started = std::time::Instant::now();
596 let t_setup = std::time::Instant::now();
597 let setup_span = crate::profile::span("setup");
599 let Session {
602 store,
603 cwd,
604 cwd_is_git,
605 root,
606 active_paths,
607 branch_refresh,
608 identity,
609 coverage,
610 } = session;
611 let cwd_is_git = *cwd_is_git;
612
613 let known = coverage.is_some();
621 let warming_ok = cwd_is_git || known;
622 if crate::trace::enabled() {
623 crate::trace!(
624 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
625 root.as_deref().map_or("?".into(), crate::trace::abbrev),
626 identity.as_deref().unwrap_or("none"),
627 coverage.as_deref().unwrap_or("none"),
628 active_paths.len(),
629 );
630 }
631 let repo_span = crate::profile::span("setup: repo state");
632 let current = identity
633 .as_deref()
634 .and_then(|id| store.repository_id(id).ok().flatten());
635 let only_repo = if all_repos { None } else { current };
638 let active = crate::search::ActiveFiles::new(active_paths.clone());
639
640 drop(repo_span);
641 let warm_span = crate::profile::span("setup: warm decision");
642
643 let warm_budget = if warm_detach_enabled() {
650 answer_warm_budget()
651 } else {
652 answer_warm_budget() + deferred_warm_budget()
653 };
654 let was_warming = coverage.as_deref() != Some("complete");
655
656 let indexed_head = (!was_warming)
667 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
668 .flatten();
669 let staleness = (!was_warming && warming_ok && !args.batch)
672 .then(|| root.clone())
673 .flatten()
674 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
675 let want_warm = warming_ok && was_warming && root.is_some();
678
679 let block = want_warm && was_warming && !no_wait;
693 let progress_ui = block && show_progress(out, stderr_interactive());
698 let indexer_budget = if block { wait_budget } else { warm_budget };
699 if progress_ui {
700 install_interrupt_handler();
701 }
702
703 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
706 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
707 crate::trace!(
708 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
709 crate::index::parse_jobs()
710 );
711 let root = root.clone().expect("checked");
712 let active = active_paths.clone();
713 let q = query.to_string();
714 let warm_done = std::sync::Arc::clone(&warm_done);
715 std::thread::spawn(move || {
716 if let Ok(mut idx) = open_store() {
717 let _ = if block {
719 crate::index::index_budgeted_cancellable(
722 &mut idx,
723 &root,
724 &active,
725 indexer_budget,
726 Some(&q),
727 &INTERRUPTED,
728 )
729 } else {
730 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
731 };
732 }
733 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
734 })
735 });
736
737 crate::trace!(
744 "setup (open + repo detect + warm decision): {} ms",
745 t_setup.elapsed().as_millis()
746 );
747 let poll_start = std::time::Instant::now();
748 let deadline = if progress_ui {
752 None
753 } else if block {
754 Some(poll_start + wait_budget)
755 } else {
756 Some(poll_start + answer_warm_budget())
757 };
758 drop(warm_span);
759 let polling = indexer.is_some() && was_warming;
760 drop(setup_span);
764 let mut query_span = crate::profile::span("query");
765 let label = repo_label(root.as_deref());
766 let mut drew_progress = false;
767 let mut last_draw = poll_start;
768 let mut hits = loop {
769 match crate::search::search(store, query, current, only_repo, &active, limit) {
770 Ok(h) => {
771 let confident = h.first().is_some_and(|hit| {
772 hit.features
773 .iter()
774 .any(|f| matches!(f.name, "exact" | "prefix"))
775 });
776 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
777 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
778 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
779 if !polling || confident || warm_finished || stopped || timed_out {
780 break h;
781 }
782 if progress_ui
783 && poll_start.elapsed() >= HEADS_UP_DELAY
784 && last_draw.elapsed() >= PROGRESS_REDRAW
785 {
786 draw_progress(store, identity.as_deref(), &label);
787 drew_progress = true;
788 last_draw = std::time::Instant::now();
789 }
790 }
791 Err(e) => {
792 if let Some(h) = indexer {
793 let _ = h.join();
794 }
795 return fail(format_args!("rq: {e}"));
796 }
797 }
798 std::thread::sleep(POLL_INTERVAL);
799 };
800 query_span.note(|| {
801 if polling {
802 "polled a warming index".to_string()
803 } else {
804 String::new()
805 }
806 });
807 drop(query_span);
808 if drew_progress {
809 clear_progress();
810 }
811 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
813
814 if !hits.is_empty() && revalidate_top(store, &hits) {
816 hits = crate::search::search(store, query, current, only_repo, &active, limit)
817 .unwrap_or_default();
818 }
819
820 if !hits.iter().any(strong)
824 && indexer.is_none()
825 && coverage.is_none()
826 && let Some(root) = &root
827 {
828 let tail = live_fallback(root, query, limit);
829 hits = crate::search::merge(hits, tail, limit);
830 }
831
832 apply_gates(query, &mut hits);
833 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
834
835 if hits.is_empty() {
836 if block {
838 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
839 }
840 if let Some(h) = indexer {
841 let _ = h.join();
842 }
843 let mut incomplete = (block || no_wait)
850 && identity
851 .as_deref()
852 .and_then(|id| store.coverage_status(id).ok().flatten())
853 .as_deref()
854 != Some("complete");
855 incomplete |= settle_warm(
865 store,
866 staleness,
867 was_warming,
868 warming_ok,
869 root.as_deref(),
870 active_paths,
871 query,
872 warm_budget,
873 no_wait,
874 identity.as_deref(),
875 );
876 return no_match_code(out, query, interrupted, incomplete);
877 }
878
879 for hit in &mut hits {
882 hit.signature = read_signature(
883 store,
884 &hit.repo_identity,
885 &hit.file,
886 hit.line,
887 cwd.as_deref(),
888 );
889 }
890 attach_confidence(&mut hits);
891
892 if show
895 && let Some(code) = show_top_definition(
896 store,
897 &mut hits,
898 query,
899 out,
900 cwd.as_deref(),
901 current,
902 no_record,
903 )
904 {
905 return code;
906 }
907
908 if open {
912 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
913 }
914
915 if let Some(code) = render_hits(args, &hits) {
916 return code;
917 }
918
919 if crate::profile::enabled() {
922 let total = profile_started.elapsed();
923 if args.out == Output::Text {
924 for line in crate::profile::report(total) {
925 eprintln!("{line}");
926 }
927 } else {
928 eprintln!("{}", crate::profile::json(total));
931 }
932 }
933
934 if let Some(refresh) = branch_refresh.take() {
941 refresh.store(store);
942 }
943
944 deferred_maintenance(store);
951
952 if block {
957 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
958 }
959 if let Some(h) = indexer {
960 let _ = h.join();
961 }
962 let _ = settle_warm(
963 store,
964 staleness,
965 was_warming,
966 warming_ok,
967 root.as_deref(),
968 active_paths,
969 query,
970 warm_budget,
971 no_wait,
972 identity.as_deref(),
973 );
974
975 ExitCode::SUCCESS
976}
977
978fn maybe_detach_warm(
981 store: &Store,
982 want_warm: bool,
983 changed: bool,
984 root: Option<&std::path::Path>,
985 identity: Option<&str>,
986) {
987 if !warm_detach_enabled() || !want_warm {
988 return;
989 }
990 let (Some(root), Some(id)) = (root, identity) else {
991 return;
992 };
993 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
997 return; }
999 spawn_detached_warm(root);
1000}
1001
1002fn spawn_detached_warm(root: &std::path::Path) {
1006 use std::os::unix::process::CommandExt;
1007 let Ok(exe) = std::env::current_exe() else {
1008 return;
1009 };
1010 let mut cmd = std::process::Command::new(exe);
1011 cmd.arg("--warm")
1012 .arg(root)
1013 .stdin(std::process::Stdio::null())
1014 .stdout(std::process::Stdio::null())
1015 .stderr(std::process::Stdio::null())
1016 .process_group(0);
1017 match cmd.spawn() {
1018 Ok(child) => crate::trace!(
1019 "background warm (detached): pid {} for {}",
1020 child.id(),
1021 crate::trace::abbrev(root)
1022 ),
1023 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1024 }
1025}
1026
1027const WARM_LOCK_TTL_SECS: i64 = 600;
1030
1031fn cmd_warm(path: Option<&str>) -> ExitCode {
1036 #[cfg(target_os = "macos")]
1039 unsafe extern "C" {
1040 fn setiopolicy_np(
1043 iotype: libc::c_int,
1044 scope: libc::c_int,
1045 policy: libc::c_int,
1046 ) -> libc::c_int;
1047 }
1048 unsafe {
1049 libc::nice(10);
1050 #[cfg(target_os = "macos")]
1051 setiopolicy_np(0, 0, 3);
1052 }
1053 let mut store = match open_store() {
1054 Ok(s) => s,
1055 Err(_) => return ExitCode::FAILURE,
1056 };
1057 let start = path
1058 .map(PathBuf::from)
1059 .or_else(|| std::env::current_dir().ok())
1060 .unwrap_or_else(|| PathBuf::from("."));
1061 let root = crate::index::repo_root(&start).unwrap_or(start);
1062 let identity = resolve_identity(&store, &root);
1063
1064 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1067 && pid != std::process::id()
1068 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1069 && now_secs() - ts < WARM_LOCK_TTL_SECS
1070 {
1071 return ExitCode::SUCCESS;
1072 }
1073 let _ = store.set_warm_lock(&identity, std::process::id());
1074
1075 let deadline = std::time::Instant::now() + warm_bg_budget();
1078 let active = crate::index::branch_changed_files(&root);
1079 loop {
1080 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1081 if remaining.is_zero() {
1082 break;
1083 }
1084 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1085 {
1086 Ok(s) => s,
1087 Err(_) => break,
1088 };
1089 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1090 || stats.files_indexed == 0
1091 {
1092 break;
1093 }
1094 }
1095 let _ = store.clear_warm_lock(&identity);
1096 ExitCode::SUCCESS
1097}
1098
1099fn now_secs() -> i64 {
1100 std::time::SystemTime::now()
1101 .duration_since(std::time::UNIX_EPOCH)
1102 .map(|d| d.as_secs() as i64)
1103 .unwrap_or(0)
1104}
1105
1106fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1109 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1110 let deadline = std::time::Instant::now() + live_fallback_budget();
1111 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1112 if !h.is_empty() {
1113 return h;
1114 }
1115 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1116}
1117
1118fn strong(h: &crate::search::Hit) -> bool {
1120 h.features
1121 .iter()
1122 .any(|f| matches!(f.name, "exact" | "prefix"))
1123}
1124
1125fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1134 if hits.iter().any(strong) {
1135 hits.retain(strong);
1136 }
1137 crate::search::apply_scope_gate(query, hits);
1138}
1139
1140fn apply_post_filters(
1143 args: &SearchArgs,
1144 cwd: Option<&std::path::Path>,
1145 root: Option<&std::path::Path>,
1146 hits: &mut Vec<crate::search::Hit>,
1147) {
1148 if !args.paths.is_empty() {
1149 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1153 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1154 let norm: Vec<String> = args
1155 .paths
1156 .iter()
1157 .map(|p| repo_relative(&base, &here, p))
1158 .collect();
1159 hits.retain(|h| under_any(&h.file, &norm));
1160 }
1161 if !args.kinds.is_empty() {
1162 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1163 }
1164 if !args.langs.is_empty() {
1165 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1166 }
1167 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1168 hits.truncate(args.want);
1169 }
1170}
1171
1172fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
1178 let status = if interrupted {
1179 "interrupted"
1180 } else if incomplete {
1181 "warming"
1182 } else {
1183 "no_match"
1184 };
1185 match out {
1186 Output::Json | Output::Ndjson => {
1187 let obj = serde_json::json!({ "status": status, "query": query });
1188 let _ = emit_json(out, &obj); }
1190 Output::Text if interrupted => {
1191 eprintln!("rq: indexing interrupted — run again to finish")
1192 }
1193 Output::Text if incomplete => eprintln!(
1194 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1195 ),
1196 Output::Text => eprintln!("no matches for {query:?}"),
1197 }
1198 if incomplete {
1199 ExitCode::from(2)
1200 } else {
1201 ExitCode::FAILURE
1202 }
1203}
1204
1205fn attach_confidence(hits: &mut [crate::search::Hit]) {
1209 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1210 if t.is_none_or(|t| h.score > t) {
1211 (Some(h.score), t)
1212 } else if s.is_none_or(|s| h.score > s) {
1213 (t, Some(h.score))
1214 } else {
1215 (t, s)
1216 }
1217 });
1218 for hit in hits.iter_mut() {
1219 let best_other = if Some(hit.score) == top { second } else { top };
1220 hit.confidence = crate::search::confidence(
1221 hit.score,
1222 crate::search::match_quality(&hit.features),
1223 best_other,
1224 );
1225 }
1226}
1227
1228fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1231 let render_span = crate::profile::span("render");
1235 if args.batch {
1236 #[derive(serde::Serialize)]
1239 struct Tagged<'a> {
1240 query: &'a str,
1241 #[serde(flatten)]
1242 hit: &'a crate::search::Hit,
1243 }
1244 let rows: Vec<Tagged> = hits
1245 .iter()
1246 .map(|hit| Tagged {
1247 query: args.query,
1248 hit,
1249 })
1250 .collect();
1251 if let Some(code) = emit_rows(args.out, &rows) {
1252 return Some(code);
1253 }
1254 } else if let Some(code) = emit_rows(args.out, hits) {
1255 return Some(code);
1256 }
1257 if args.out != Output::Text {
1258 return None;
1259 }
1260 drop(render_span);
1261 let color = match_color();
1262 let c = color.as_deref();
1263 let query = args.query;
1264 if args.show {
1265 eprintln!(
1267 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1268 hits.len()
1269 );
1270 }
1271 for hit in hits {
1272 let name = hl(&hit.name, query, c);
1275 let qualified = match &hit.parent {
1276 Some(p) => format!("{name} · {p}"),
1277 None => name,
1278 };
1279 println!(
1280 "{}:{} {} {}",
1281 hl_path(&hit.file, query, c),
1282 hit.line,
1283 hit.kind,
1284 qualified
1285 );
1286 if let Some(sig) = &hit.signature {
1287 println!(" {}", hl(sig, query, c));
1288 }
1289 if args.explain {
1290 let parts: Vec<String> = hit
1291 .features
1292 .iter()
1293 .map(|f| format!("{} {:.0}", f.name, f.value))
1294 .collect();
1295 println!(
1296 " confidence {:.2} · score {:.0} = {}",
1297 hit.confidence,
1298 hit.score,
1299 parts.join(" + ")
1300 );
1301 }
1302 }
1303 None
1304}
1305
1306fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1310 use std::io::{IsTerminal, Write};
1311 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1312 return hits.first();
1313 }
1314 let mut err = std::io::stderr();
1315 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1316 for (i, h) in hits.iter().enumerate() {
1317 let _ = writeln!(
1318 err,
1319 " {}. {}:{} {} {}",
1320 i + 1,
1321 h.file,
1322 h.line,
1323 h.kind,
1324 h.name
1325 );
1326 }
1327 let _ = write!(err, "rq> ");
1328 let _ = err.flush();
1329 let mut line = String::new();
1330 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1331 return None; }
1333 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1334}
1335
1336fn parse_choice(input: &str, n: usize) -> Option<usize> {
1339 let s = input.trim();
1340 if s.is_empty() {
1341 return Some(0);
1342 }
1343 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1344 (i < n).then_some(i)
1345}
1346
1347fn finish_open(
1351 store: &mut Store,
1352 hits: &[crate::search::Hit],
1353 query: &str,
1354 current: Option<i64>,
1355 root: Option<&std::path::Path>,
1356 no_record: bool,
1357) -> ExitCode {
1358 let Some(hit) = choose_hit(hits) else {
1359 return ExitCode::SUCCESS; };
1361
1362 if !no_record {
1365 let _ = store.record_event(
1366 "select",
1367 Some(&query.to_ascii_lowercase()),
1368 current,
1369 Some(&hit.file),
1370 Some(hit.line),
1371 None,
1372 );
1373 deferred_maintenance(store);
1374 }
1375
1376 let target = match root {
1379 Some(r) => r.join(&hit.file),
1380 None => PathBuf::from(&hit.file),
1381 };
1382 launch_editor(&target, hit.line)
1383}
1384
1385fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1389 use std::os::unix::process::CommandExt;
1390 let loc = format!("{}:{}", file.display(), line);
1391 match open_command(file, line, &loc) {
1392 Some((prog, args)) => {
1393 let err = std::process::Command::new(&prog).args(&args).exec();
1395 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1396 }
1397 None => {
1398 println!("{loc}");
1399 ExitCode::SUCCESS
1400 }
1401 }
1402}
1403
1404fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1408 let fstr = file.to_string_lossy().into_owned();
1409
1410 if let Some(t) = std::env::var_os("RQ_OPEN") {
1411 let t = t.to_string_lossy();
1412 let mut parts = t.split_whitespace().map(|p| {
1413 p.replace("{file}", &fstr)
1414 .replace("{line}", &line.to_string())
1415 .replace("{}", loc)
1416 });
1417 if let Some(prog) = parts.next() {
1418 return Some((prog, parts.collect()));
1419 }
1420 }
1421
1422 if on_path("code") {
1423 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1424 }
1425
1426 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1427 let ed = ed.to_string_lossy().into_owned();
1428 let l = ed.to_ascii_lowercase();
1429 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1431 .iter()
1432 .any(|e| l.contains(e))
1433 {
1434 return Some((ed, vec![format!("+{line}"), fstr]));
1435 }
1436 return Some((ed, vec![fstr]));
1437 }
1438
1439 None
1440}
1441
1442fn on_path(prog: &str) -> bool {
1444 std::env::var_os("PATH")
1445 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1446}
1447
1448fn unix_now() -> i64 {
1458 std::time::SystemTime::now()
1459 .duration_since(std::time::UNIX_EPOCH)
1460 .map(|d| d.as_secs() as i64)
1461 .unwrap_or(0)
1462}
1463
1464const BRANCH_FILES_TTL_SECS: i64 = 15;
1470
1471struct BranchRefresh {
1475 handle: std::thread::JoinHandle<Vec<String>>,
1476 identity: String,
1477 stamp: String,
1478}
1479
1480impl BranchRefresh {
1481 fn store(self, store: &Store) {
1483 let Ok(files) = self.handle.join() else {
1484 return;
1485 };
1486 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1487 }
1488}
1489
1490fn cached_branch_files(
1504 store: &Store,
1505 root: &std::path::Path,
1506) -> (Vec<String>, Option<BranchRefresh>) {
1507 let identity = resolve_identity(store, root);
1508 let stamp = crate::index::branch_files_stamp(root);
1509 let cached = store.branch_files_get(&identity).ok().flatten();
1510 let now = unix_now();
1511
1512 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1513 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1514 return (files.clone(), None);
1515 }
1516 let owned_root = root.to_path_buf();
1517 let refresh = BranchRefresh {
1518 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1519 identity,
1520 stamp: stamp.clone(),
1521 };
1522 return (files.clone(), Some(refresh));
1523 }
1524
1525 let files = crate::index::branch_changed_files(root);
1527 if let Some(stamp) = stamp {
1528 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1529 }
1530 (files, None)
1531}
1532
1533fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1542 let Some(head) = indexed_head else {
1543 return true;
1544 };
1545 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1546}
1547
1548#[allow(clippy::too_many_arguments)]
1556fn settle_warm(
1557 store: &Store,
1558 staleness: Option<std::thread::JoinHandle<bool>>,
1559 was_warming: bool,
1560 warming_ok: bool,
1561 root: Option<&std::path::Path>,
1562 active: &[String],
1563 query: &str,
1564 budget: Duration,
1565 no_wait: bool,
1566 identity: Option<&str>,
1567) -> bool {
1568 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1571 if changed
1581 && !no_wait
1582 && !warm_detach_enabled()
1583 && let Some(r) = root
1584 && let Ok(mut idx) = open_store()
1585 {
1586 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1587 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1588 }
1589 maybe_detach_warm(
1590 store,
1591 warming_ok && (was_warming || changed),
1592 changed,
1593 root,
1594 identity,
1595 );
1596 changed && warm_detach_enabled()
1600}
1601
1602fn answer_warm_budget() -> Duration {
1611 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1612}
1613
1614fn deferred_warm_budget() -> Duration {
1617 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1618}
1619
1620fn live_fallback_budget() -> Duration {
1623 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1624}
1625
1626fn warm_bg_budget() -> Duration {
1629 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1630}
1631
1632fn warm_detach_enabled() -> bool {
1636 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1637}
1638
1639fn wait_budget() -> Duration {
1648 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1649}
1650
1651fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1656 let s = s.trim();
1657 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1658 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1660 (n, 1.0)
1661 } else if let Some(n) = s.strip_suffix('s') {
1662 (n, 1_000.0)
1663 } else if let Some(n) = s.strip_suffix('m') {
1664 (n, 60_000.0)
1665 } else {
1666 (s, 1_000.0)
1668 };
1669 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1670 if !val.is_finite() || val < 0.0 {
1671 return Err(bad());
1672 }
1673 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1674}
1675
1676static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1680
1681extern "C" fn on_sigint(_: libc::c_int) {
1682 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1684}
1685
1686fn install_interrupt_handler() {
1689 static ONCE: std::sync::Once = std::sync::Once::new();
1690 ONCE.call_once(|| unsafe {
1691 let mut action: libc::sigaction = std::mem::zeroed();
1692 action.sa_sigaction = on_sigint as *const () as usize;
1693 libc::sigemptyset(&mut action.sa_mask);
1694 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1695 });
1696}
1697
1698fn stderr_interactive() -> bool {
1702 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1703}
1704
1705fn show_progress(out: Output, interactive: bool) -> bool {
1710 interactive && matches!(out, Output::Text)
1711}
1712
1713fn repo_label(root: Option<&std::path::Path>) -> String {
1716 root.and_then(|r| r.file_name())
1717 .map(|n| n.to_string_lossy().into_owned())
1718 .unwrap_or_else(|| "repo".into())
1719}
1720
1721fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1725 let files = identity
1726 .and_then(|id| store.repository_id(id).ok().flatten())
1727 .and_then(|rid| store.repo_totals(rid).ok())
1728 .map_or(0, |(f, _)| f);
1729 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1730 let _ = std::io::stderr().flush();
1731}
1732
1733fn clear_progress() {
1735 eprint!("\r\x1b[K");
1736 let _ = std::io::stderr().flush();
1737}
1738
1739fn env_budget(var: &str, default_ms: u64) -> Duration {
1743 let ms = std::env::var(var)
1744 .ok()
1745 .and_then(|v| v.parse().ok())
1746 .unwrap_or(default_ms);
1747 Duration::from_millis(ms)
1748}
1749
1750const AGGREGATE_BATCH: usize = 256;
1753
1754const KEEP_RECENT_EVENTS: i64 = 200;
1757
1758fn deferred_maintenance(store: &mut Store) {
1761 let _ = store.aggregate_events(AGGREGATE_BATCH);
1762 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1763}
1764
1765fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1768 let mut store = match open_store() {
1769 Ok(s) => s,
1770 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1771 };
1772 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1773 let identity = crate::index::detect_identity(&cwd).to_string();
1774 let repo_id = store.repository_id(&identity).ok().flatten();
1775
1776 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1779 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1780 None => file.to_string(),
1781 };
1782 let query_norm = query.map(|q| q.to_ascii_lowercase());
1783
1784 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1785 {
1786 return fail(format_args!("rq record: {e}"));
1787 }
1788 deferred_maintenance(&mut store);
1789 ExitCode::SUCCESS
1790}
1791
1792fn hit_file_roots(
1798 store: &Store,
1799 repo_identity: &str,
1800 cwd: Option<&std::path::Path>,
1801) -> Vec<PathBuf> {
1802 let mut roots: Vec<PathBuf> = store
1803 .repository_id(repo_identity)
1804 .ok()
1805 .flatten()
1806 .map(|id| store.checkout_roots(id).unwrap_or_default())
1807 .unwrap_or_default()
1808 .into_iter()
1809 .map(PathBuf::from)
1810 .collect();
1811 if let Some(c) = cwd {
1812 let c = c.to_path_buf();
1813 if !roots.contains(&c) {
1814 roots.push(c);
1815 }
1816 }
1817 roots
1818}
1819
1820fn read_signature(
1823 store: &Store,
1824 repo_identity: &str,
1825 file: &str,
1826 line: i64,
1827 cwd: Option<&std::path::Path>,
1828) -> Option<String> {
1829 hit_file_roots(store, repo_identity, cwd)
1830 .into_iter()
1831 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1832}
1833
1834const SHOW_CONFIDENCE: f64 = 0.85;
1838
1839fn show_top_definition(
1848 store: &mut Store,
1849 hits: &mut [crate::search::Hit],
1850 query: &str,
1851 out: Output,
1852 cwd: Option<&std::path::Path>,
1853 current: Option<i64>,
1854 no_record: bool,
1855) -> Option<ExitCode> {
1856 let top = hits.first()?;
1857 if top.confidence < SHOW_CONFIDENCE {
1858 return None; }
1860 let end = top.end_line.unwrap_or(top.line);
1861 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1862 hits[0].body = body;
1863 let top = &hits[0];
1864 let shown = (top.file.clone(), top.line);
1865 let code = match out {
1866 Output::Json | Output::Ndjson => {
1867 emit_json(out, top)
1869 }
1870 Output::Text => {
1871 let color = match_color();
1872 let c = color.as_deref();
1873 let name = hl(&top.name, query, c);
1874 let qualified = match &top.parent {
1875 Some(p) => format!("{name} · {p}"),
1876 None => name,
1877 };
1878 println!(
1879 "{}:{} {} {}",
1880 hl_path(&top.file, query, c),
1881 top.line,
1882 top.kind,
1883 qualified
1884 );
1885 match (&top.body, &top.signature) {
1886 (Some(body), _) => println!("{body}"),
1887 (None, Some(sig)) => println!("{sig}"),
1889 (None, None) => {}
1890 }
1891 ExitCode::SUCCESS
1892 }
1893 };
1894
1895 if !no_record {
1897 let (file, line) = shown;
1898 let _ = store.record_event(
1899 "select",
1900 Some(&query.to_ascii_lowercase()),
1901 current,
1902 Some(&file),
1903 Some(line),
1904 None,
1905 );
1906 deferred_maintenance(store);
1907 }
1908 Some(code)
1909}
1910
1911fn read_span(
1914 store: &Store,
1915 repo_identity: &str,
1916 file: &str,
1917 start: i64,
1918 end: i64,
1919 cwd: Option<&std::path::Path>,
1920) -> Option<String> {
1921 hit_file_roots(store, repo_identity, cwd)
1922 .into_iter()
1923 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1924}
1925
1926fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1929 let s = usize::try_from(start).ok()?.checked_sub(1)?;
1930 let lines: Vec<&str> = content.lines().collect();
1931 if s >= lines.len() {
1932 return None;
1933 }
1934 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1935 Some(lines[s..e].join("\n"))
1936}
1937
1938fn signature_in(content: &str, line: i64) -> Option<String> {
1942 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1943 let l = content.lines().nth(idx)?.trim();
1944 (!l.is_empty()).then(|| l.to_string())
1945}
1946
1947#[derive(serde::Serialize)]
1951struct SymbolOut {
1952 name: String,
1953 kind: String,
1954 language: String,
1955 file: String,
1956 line: i64,
1957 #[serde(skip_serializing_if = "Option::is_none")]
1958 end_line: Option<i64>,
1959 #[serde(skip_serializing_if = "Option::is_none")]
1960 parent: Option<String>,
1961 #[serde(skip_serializing_if = "Option::is_none")]
1962 visibility: Option<String>,
1963 repo: String,
1964 #[serde(skip_serializing_if = "Option::is_none")]
1965 signature: Option<String>,
1966}
1967
1968fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
1973 let mut store = match open_store() {
1974 Ok(s) => s,
1975 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1976 };
1977 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1978 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
1979 let rel = repo_relative(&root, &cwd, file_arg);
1980
1981 let identity = resolve_identity(&store, &root);
1982 let coverage = store.coverage_status(&identity).ok().flatten();
1983 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
1984 let current = store.repository_id(&identity).ok().flatten();
1985 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
1988 let needs_warm = warming_ok
1989 && (coverage.as_deref() != Some("complete")
1990 || worktree_changed(&root, indexed_head.as_deref()));
1991 if needs_warm {
1992 let budget = answer_warm_budget() + deferred_warm_budget();
1994 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
1995 }
1996
1997 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
1998 return emit_symbols(out, &[]); };
2000 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2001 Ok(r) => r,
2002 Err(e) => return fail(format_args!("rq: {e}")),
2003 };
2004 if !kinds.is_empty() {
2005 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2006 }
2007 if !langs.is_empty() {
2008 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2009 }
2010
2011 let content = hit_file_roots(&store, &identity, Some(&root))
2015 .iter()
2016 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
2017 let syms: Vec<SymbolOut> = rows
2018 .into_iter()
2019 .map(|r| SymbolOut {
2020 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
2021 name: r.name,
2022 kind: r.kind,
2023 language: r.language,
2024 file: r.file,
2025 line: r.line,
2026 end_line: r.end_line,
2027 parent: r.parent,
2028 visibility: r.visibility,
2029 repo: r.repo_identity,
2030 })
2031 .collect();
2032 emit_symbols(out, &syms)
2033}
2034
2035fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2038 if syms.is_empty() {
2039 match out {
2040 Output::Json | Output::Ndjson => {
2041 let obj = serde_json::json!({ "status": "no_match" });
2042 let _ = emit_json(out, &obj); }
2044 Output::Text => eprintln!("no symbols"),
2045 }
2046 return ExitCode::FAILURE;
2047 }
2048 if let Some(code) = emit_rows(out, syms) {
2049 return code;
2050 }
2051 match out {
2052 Output::Json | Output::Ndjson => {}
2053 Output::Text => {
2054 for s in syms {
2055 let qualified = match &s.parent {
2056 Some(p) => format!("{} · {p}", s.name),
2057 None => s.name.clone(),
2058 };
2059 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2060 if let Some(sig) = &s.signature {
2061 println!(" {sig}");
2062 }
2063 }
2064 }
2065 }
2066 ExitCode::SUCCESS
2067}
2068
2069fn keyword_kind(token: &str) -> Option<&'static str> {
2074 match token.to_ascii_lowercase().as_str() {
2075 "class" => Some("class"),
2076 "module" => Some("module"),
2077 "method" => Some("method"),
2078 "function" | "fn" => Some("function"),
2079 "struct" | "type" => Some("struct"),
2080 "enum" => Some("enum"),
2081 "trait" | "interface" => Some("trait"),
2082 _ => None,
2083 }
2084}
2085
2086fn split_kind_keyword(
2092 target: String,
2093 dirs: Vec<String>,
2094) -> (Option<&'static str>, String, Vec<String>) {
2095 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2098 let rest = rest.trim();
2099 if let Some(k) = keyword_kind(head)
2100 && !rest.is_empty()
2101 {
2102 return (Some(k), rest.to_string(), dirs);
2103 }
2104 } else if let Some(k) = keyword_kind(&target)
2105 && let Some((query, extra)) = dirs.split_first()
2106 {
2107 return (Some(k), query.clone(), extra.to_vec());
2109 }
2110 (None, target, dirs)
2111}
2112
2113fn canonical_kind(s: &str) -> String {
2116 match s.to_ascii_lowercase().as_str() {
2117 "c" | "class" => "class",
2118 "m" | "method" => "method",
2119 "f" | "fn" | "func" | "function" => "function",
2120 "mod" | "module" => "module",
2121 "s" | "struct" | "type" => "struct",
2122 "e" | "enum" => "enum",
2123 "t" | "trait" | "interface" => "trait",
2124 other => return other.to_string(),
2125 }
2126 .to_string()
2127}
2128
2129fn canonical_langs(s: &str) -> Vec<String> {
2136 let t = s.to_ascii_lowercase();
2137 let alias = match t.as_str() {
2138 "rb" => Some("ruby"),
2139 "rs" => Some("rust"),
2140 "golang" => Some("go"),
2141 "ts" | "tsx" => Some("typescript"),
2142 "js" | "jsx" => Some("javascript"),
2143 _ => None,
2144 };
2145 let matched: Vec<String> = crate::lang::languages()
2146 .into_iter()
2147 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2148 .map(str::to_string)
2149 .collect();
2150 if matched.is_empty() { vec![t] } else { matched }
2151}
2152
2153fn match_color() -> Option<String> {
2157 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2158 return None;
2159 }
2160 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2161 gc.split(':').find_map(|e| {
2162 e.strip_prefix("mt=")
2163 .or_else(|| e.strip_prefix("ms="))
2164 .filter(|v| !v.is_empty())
2165 .map(str::to_string)
2166 })
2167 });
2168 Some(style.unwrap_or_else(|| "1;31".to_string()))
2169}
2170
2171fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2174 match color {
2175 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2176 None => text.to_string(),
2177 }
2178}
2179
2180fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2183 let Some(c) = color else {
2184 return path.to_string();
2185 };
2186 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2187 let base_start = path[..base_byte].chars().count();
2188 let stem = crate::search::path_stem(path);
2192 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2193 .into_iter()
2194 .map(|p| p + base_start)
2195 .collect();
2196 highlight(path, &positions, c)
2197}
2198
2199fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2202 if positions.is_empty() {
2203 return text.to_string();
2204 }
2205 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2206 let mut out = String::new();
2207 let mut on = false;
2208 for (i, c) in text.chars().enumerate() {
2209 match (matched.contains(&i), on) {
2210 (true, false) => {
2211 out.push_str("\x1b[");
2212 out.push_str(color);
2213 out.push('m');
2214 on = true;
2215 }
2216 (false, true) => {
2217 out.push_str("\x1b[0m");
2218 on = false;
2219 }
2220 _ => {}
2221 }
2222 out.push(c);
2223 }
2224 if on {
2225 out.push_str("\x1b[0m");
2226 }
2227 out
2228}
2229
2230fn under_any(file: &str, paths: &[String]) -> bool {
2234 paths.iter().any(|p| {
2235 let p = p.trim_start_matches("./").trim_end_matches('/');
2236 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2237 })
2238}
2239
2240fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2242 let p = std::path::Path::new(file);
2243 let abs = if p.is_absolute() {
2244 p.to_path_buf()
2245 } else {
2246 cwd.join(p)
2247 };
2248 let abs = abs.canonicalize().unwrap_or(abs);
2249 abs.strip_prefix(root)
2250 .map(|r| r.to_string_lossy().into_owned())
2251 .unwrap_or_else(|_| file.to_string())
2252}
2253
2254fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2258 use std::collections::HashSet;
2259 let mut seen = HashSet::new();
2260 let mut changed = false;
2261 for hit in hits {
2262 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2263 continue;
2264 }
2265 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2266 continue;
2267 };
2268 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2269 continue;
2270 };
2271 if let Ok(crate::index::Refresh::Updated) =
2272 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2273 {
2274 changed = true;
2275 }
2276 }
2277 changed
2278}
2279
2280fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2286 if let Ok(canon) = cwd.canonicalize() {
2287 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2288 return identity;
2289 }
2290 if crate::index::repo_root(cwd).is_none() {
2291 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2292 }
2293 }
2294 crate::index::detect_identity(cwd).to_string()
2295}
2296
2297fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2298 let explicit = path.is_some();
2299 let target = path.unwrap_or_else(|| PathBuf::from("."));
2300 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2305 let mut subdirs = subdirs.to_vec();
2310 if explicit
2311 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2312 && t != r
2313 && let Ok(rel) = t.strip_prefix(&r)
2314 && !rel.as_os_str().is_empty()
2315 {
2316 subdirs.push(rel.to_string_lossy().into_owned());
2317 }
2318 let mut store = match open_store() {
2319 Ok(s) => s,
2320 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2321 };
2322 let identity = crate::index::detect_identity(&root).to_string();
2323 match crate::index::index_under(&mut store, &root, &subdirs) {
2324 Ok(stats) => {
2325 let subtree = !subdirs.is_empty();
2326 let totals = store
2328 .repository_id(&identity)
2329 .ok()
2330 .flatten()
2331 .and_then(|id| store.repo_totals(id).ok());
2332 match out {
2333 Output::Json | Output::Ndjson => {
2334 let (files, symbols) = match totals {
2335 Some((f, s)) => (Some(f), Some(s)),
2336 None => (None, None),
2337 };
2338 return emit_json(
2339 out,
2340 &serde_json::json!({
2341 "repo": identity,
2342 "scope": if subtree { "subtree" } else { "full" },
2343 "files_added": stats.files_indexed,
2344 "symbols_added": stats.symbols,
2345 "files": files,
2346 "symbols": symbols,
2347 }),
2348 );
2349 }
2350 Output::Text => {
2351 let scope = if subtree { " (subtree seed)" } else { "" };
2352 match totals {
2353 Some((files, symbols)) => println!(
2354 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2355 stats.files_indexed, stats.symbols
2356 ),
2357 None => println!(
2358 "{} file(s)/{} symbol(s) added this run{scope}",
2359 stats.files_indexed, stats.symbols
2360 ),
2361 }
2362 }
2363 }
2364 ExitCode::SUCCESS
2365 }
2366 Err(e) => fail(format_args!("rq --index: {e}")),
2367 }
2368}
2369
2370fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2371 let mut store = match open_store() {
2372 Ok(s) => s,
2373 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2374 };
2375
2376 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2380 let root = crate::index::repo_root(&path).unwrap_or(path);
2381 let from_path = crate::index::detect_identity(&root).to_string();
2382 let resolved = match store.repository_id(&from_path) {
2383 Ok(Some(id)) => Some((from_path.clone(), id)),
2384 Ok(None) => target.as_deref().and_then(|s| {
2385 store
2386 .repository_id(s)
2387 .ok()
2388 .flatten()
2389 .map(|id| (s.to_string(), id))
2390 }),
2391 Err(e) => return fail(format_args!("rq --drop: {e}")),
2392 };
2393
2394 let Some((identity, repo_id)) = resolved else {
2395 return match out {
2397 Output::Text => {
2398 println!("not indexed: {from_path}");
2399 ExitCode::SUCCESS
2400 }
2401 _ => emit_json(
2402 out,
2403 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2404 ),
2405 };
2406 };
2407
2408 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2409 match store.drop_repository(repo_id) {
2410 Ok(()) => match out {
2411 Output::Text => {
2412 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2413 ExitCode::SUCCESS
2414 }
2415 _ => emit_json(
2416 out,
2417 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2418 ),
2419 },
2420 Err(e) => fail(format_args!("rq --drop: {e}")),
2421 }
2422}
2423
2424fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2428 let rendered = if out == Output::Json {
2429 serde_json::to_string_pretty(value)
2430 } else {
2431 serde_json::to_string(value)
2432 };
2433 match rendered {
2434 Ok(s) => {
2435 println!("{s}");
2436 ExitCode::SUCCESS
2437 }
2438 Err(e) => fail(format_args!("rq: {e}")),
2439 }
2440}
2441
2442fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2446 match out {
2447 Output::Json => match serde_json::to_string_pretty(rows) {
2448 Ok(s) => println!("{s}"),
2449 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2450 },
2451 Output::Ndjson => {
2452 for r in rows {
2453 match serde_json::to_string(r) {
2454 Ok(line) => println!("{line}"),
2455 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2456 }
2457 }
2458 }
2459 Output::Text => {}
2460 }
2461 None
2462}
2463
2464fn cmd_status(out: Output) -> ExitCode {
2465 let store = match open_store() {
2466 Ok(s) => s,
2467 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2468 };
2469 let rows = match store.coverage_overview() {
2470 Ok(rows) => rows,
2471 Err(e) => return fail(format_args!("rq --status: {e}")),
2472 };
2473 if let Some(code) = emit_rows(out, &rows) {
2474 return code;
2475 }
2476 match out {
2477 Output::Json | Output::Ndjson => {}
2478 Output::Text if rows.is_empty() => {
2479 println!("no repositories indexed yet (try `rq --index`)");
2480 }
2481 Output::Text => {
2482 for r in &rows {
2483 println!(
2484 "{:<10} {:>6} files {:>7} symbols {}",
2485 r.status, r.files, r.symbols, r.identity
2486 );
2487 }
2488 }
2489 }
2490 ExitCode::SUCCESS
2491}
2492
2493fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2495 let path = db_path()?;
2496 if let Some(parent) = path.parent() {
2497 std::fs::create_dir_all(parent)?;
2498 }
2499 Ok(Store::open(&path)?)
2500}
2501
2502fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2504 if let Ok(p) = std::env::var("RQ_DB") {
2505 return Ok(PathBuf::from(p));
2506 }
2507 let home = std::env::var("HOME")?;
2508 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2509}
2510
2511fn fail(args: std::fmt::Arguments) -> ExitCode {
2512 eprintln!("{args}");
2513 ExitCode::FAILURE
2514}
2515
2516#[cfg(test)]
2517mod tests {
2518 use super::*;
2519
2520 #[test]
2521 fn open_menu_choice_parsing() {
2522 assert_eq!(parse_choice("\n", 5), Some(0));
2524 assert_eq!(parse_choice(" ", 5), Some(0));
2525 assert_eq!(parse_choice("3", 5), Some(2));
2526 assert_eq!(parse_choice("5", 5), Some(4));
2527 assert_eq!(parse_choice("6", 5), None);
2529 assert_eq!(parse_choice("0", 5), None);
2530 assert_eq!(parse_choice("q", 5), None);
2531 }
2532
2533 #[test]
2534 fn wait_duration_parsing() {
2535 use std::time::Duration;
2536 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2538 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2539 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2540 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2541 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2543 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2544 assert!(parse_wait("0s").unwrap().is_zero());
2545 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2547 assert!(parse_wait("2x").is_err());
2549 assert!(parse_wait("").is_err());
2550 assert!(parse_wait("s").is_err());
2551 assert!(parse_wait("-1s").is_err());
2552 }
2553
2554 #[test]
2555 fn leading_kind_keyword_becomes_a_kind_filter() {
2556 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2557 assert_eq!(
2559 split_kind_keyword("class".into(), d(&["Widget"])),
2560 (Some("class"), "Widget".into(), vec![])
2561 );
2562 assert_eq!(
2564 split_kind_keyword("method zoom".into(), vec![]),
2565 (Some("method"), "zoom".into(), vec![])
2566 );
2567 assert_eq!(
2569 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2570 (Some("function"), "Foo::run".into(), vec![])
2571 );
2572 assert_eq!(
2574 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2575 (Some("struct"), "Gadget".into(), d(&["src"]))
2576 );
2577 }
2578
2579 #[test]
2580 fn a_bare_or_non_keyword_query_is_left_alone() {
2581 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2582 assert_eq!(
2584 split_kind_keyword("class".into(), vec![]),
2585 (None, "class".into(), vec![])
2586 );
2587 assert_eq!(
2589 split_kind_keyword("Widget".into(), d(&["app"])),
2590 (None, "Widget".into(), d(&["app"]))
2591 );
2592 assert_eq!(
2594 split_kind_keyword("c".into(), d(&["Foo"])),
2595 (None, "c".into(), d(&["Foo"]))
2596 );
2597 }
2598
2599 #[test]
2600 fn a_language_selects_by_prefix_or_alias() {
2601 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2603 assert_eq!(canonical_langs("t"), ["typescript"]);
2604 assert_eq!(canonical_langs("ts"), ["typescript"]);
2606 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2607 assert_eq!(canonical_langs("rb"), ["ruby"]);
2608 assert_eq!(canonical_langs("COBOL"), ["cobol"]);
2610 }
2611
2612 #[test]
2613 fn a_kind_normalizes_language_specific_spellings() {
2614 assert_eq!(canonical_kind("f"), "function");
2615 assert_eq!(canonical_kind("interface"), "trait");
2617 assert_eq!(canonical_kind("type"), "struct");
2618 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2620 assert_eq!(
2621 split_kind_keyword("interface".into(), d(&["Renderer"])),
2622 (Some("trait"), "Renderer".into(), vec![])
2623 );
2624 }
2625
2626 #[test]
2627 fn highlight_wraps_matched_runs() {
2628 assert_eq!(
2629 highlight("FooThing", &[0, 1, 2], "1;31"),
2630 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2631 );
2632 assert_eq!(
2634 highlight("FooThing", &[0, 3], "1"),
2635 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2636 );
2637 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2639 }
2640
2641 #[test]
2642 fn progress_ui_only_for_an_interactive_text_terminal() {
2643 assert!(show_progress(Output::Text, true));
2645
2646 assert!(!show_progress(Output::Json, true));
2648 assert!(!show_progress(Output::Ndjson, true));
2649
2650 assert!(!show_progress(Output::Text, false));
2652 }
2653
2654 #[test]
2655 fn repo_label_uses_the_directory_name() {
2656 assert_eq!(
2657 repo_label(Some(std::path::Path::new("/src/widgets"))),
2658 "widgets"
2659 );
2660 assert_eq!(repo_label(None), "repo");
2661 }
2662
2663 #[test]
2664 fn hl_path_highlights_the_stem_not_the_extension() {
2665 let out = hl_path(
2668 "app/employees_controller.rb",
2669 "employeescontroller",
2670 Some("1;31"),
2671 );
2672 assert!(
2673 out.starts_with("app/\u{1b}[1;31memployees"),
2674 "stem highlighted: {out:?}"
2675 );
2676 assert!(
2677 out.ends_with("controller\u{1b}[0m.rb"),
2678 "`.rb` left un-highlighted: {out:?}"
2679 );
2680 }
2681}