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(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: 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
323const POLL_INTERVAL: Duration = Duration::from_millis(100);
330
331const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
335
336const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
340
341struct SearchArgs<'a> {
343 query: &'a str,
344 explain: bool,
345 out: Output,
346 paths: &'a [String],
347 kinds: &'a [String],
348 langs: &'a [String],
349 want: usize,
351 no_record: bool,
352 no_wait: bool,
354 wait: Option<Duration>,
357 open: bool,
358 all_repos: bool,
359 batch: bool,
363 show: bool,
364}
365
366struct Session {
377 store: Store,
378 cwd: Option<PathBuf>,
379 cwd_is_git: bool,
380 root: Option<PathBuf>,
381 active_paths: Vec<String>,
382 branch_refresh: Option<BranchRefresh>,
383 identity: Option<String>,
384 coverage: Option<String>,
385}
386
387impl Session {
388 fn open() -> std::result::Result<Session, ExitCode> {
390 let open_span = crate::profile::span("store open");
391 let store = match open_store() {
392 Ok(s) => s,
393 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
394 };
395 drop(open_span);
396 let git_span = crate::profile::span("setup: git root");
397 let cwd = std::env::current_dir().ok();
398 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
399
400 let root = cwd
406 .as_deref()
407 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
408 drop(git_span);
409
410 let mut branch_span = crate::profile::span("setup: branch files");
413 let (active_paths, branch_refresh) = match &root {
414 Some(c) if cwd_is_git => cached_branch_files(&store, c),
415 _ => (Vec::new(), None),
416 };
417 branch_span.note(|| {
418 let how = if branch_refresh.is_some() {
419 "cached, refreshing alongside"
420 } else {
421 "cached"
422 };
423 format!("{} changed, {how}", active_paths.len())
424 });
425 drop(branch_span);
426
427 let mut identity_span = crate::profile::span("setup: identity");
432 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
433 let coverage = identity
434 .as_deref()
435 .and_then(|id| store.coverage_status(id).ok())
436 .flatten();
437 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
438 drop(identity_span);
439 Ok(Session {
440 store,
441 cwd,
442 cwd_is_git,
443 root,
444 active_paths,
445 branch_refresh,
446 identity,
447 coverage,
448 })
449 }
450}
451
452fn cmd_batch(
467 cli: &Cli,
468 out: Output,
469 paths: &[String],
470 kinds: &[String],
471 langs: &[String],
472) -> ExitCode {
473 if out == Output::Json {
474 return fail(format_args!(
475 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
476 where each line carries the query it answers"
477 ));
478 }
479 if cli.open || cli.show {
480 return fail(format_args!(
481 "rq: --open and --show act on a single result, not a stream of queries"
482 ));
483 }
484
485 use std::io::BufRead;
486 let queries: Vec<String> = std::io::stdin()
487 .lock()
488 .lines()
489 .map_while(std::result::Result::ok)
490 .map(|l| l.trim().to_string())
491 .filter(|l| !l.is_empty())
492 .collect();
493 if queries.is_empty() {
497 let _ = Cli::command().print_long_help();
498 return ExitCode::SUCCESS;
499 }
500
501 let mut session = match Session::open() {
502 Ok(s) => s,
503 Err(code) => return code,
504 };
505
506 if !cli.no_wait
509 && session.coverage.as_deref() != Some("complete")
510 && let Some(root) = session.root.clone()
511 {
512 {
513 let budget = cli.wait.unwrap_or_else(wait_budget);
514 crate::trace!(
515 "batch: warming {} queries' worth of index first",
516 queries.len()
517 );
518 let active = session.active_paths.clone();
519 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
520 session.coverage = session
521 .identity
522 .as_deref()
523 .and_then(|id| session.store.coverage_status(id).ok())
524 .flatten();
525 }
526 }
527
528 let mut worst = ExitCode::SUCCESS;
529 let mut any_hit = false;
530 for query in &queries {
531 let code = cmd_search(
532 &mut session,
533 &SearchArgs {
534 query,
535 explain: cli.explain,
536 out,
537 paths,
538 kinds,
539 langs,
540 want: cli.limit,
541 no_record: cli.no_record,
542 no_wait: true,
546 wait: cli.wait,
547 open: false,
548 all_repos: cli.all_repos,
549 show: false,
550 batch: true,
551 },
552 );
553 if code == ExitCode::SUCCESS {
554 any_hit = true;
555 } else {
556 worst = code;
557 }
558 }
559 if any_hit { ExitCode::SUCCESS } else { worst }
562}
563
564fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
565 let &SearchArgs {
566 query,
567 out,
568 want,
569 no_record,
570 no_wait,
571 wait,
572 open,
573 all_repos,
574 show,
575 ..
576 } = args;
577 let wait_budget = wait.unwrap_or_else(wait_budget);
580 let no_wait = no_wait || wait_budget.is_zero();
581 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
584 want
585 } else {
586 (want * 20).max(PATH_HEADROOM)
587 };
588 let _timer = crate::trace::Timer::start("search done");
589 let profile_started = std::time::Instant::now();
590 let t_setup = std::time::Instant::now();
591 let setup_span = crate::profile::span("setup");
593 let Session {
596 store,
597 cwd,
598 cwd_is_git,
599 root,
600 active_paths,
601 branch_refresh,
602 identity,
603 coverage,
604 } = session;
605 let cwd_is_git = *cwd_is_git;
606
607 let known = coverage.is_some();
615 let warming_ok = cwd_is_git || known;
616 if crate::trace::enabled() {
617 crate::trace!(
618 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
619 root.as_deref().map_or("?".into(), crate::trace::abbrev),
620 identity.as_deref().unwrap_or("none"),
621 coverage.as_deref().unwrap_or("none"),
622 active_paths.len(),
623 );
624 }
625 let repo_span = crate::profile::span("setup: repo state");
626 let current = identity
627 .as_deref()
628 .and_then(|id| store.repository_id(id).ok().flatten());
629 let only_repo = if all_repos { None } else { current };
632 let active = crate::search::ActiveFiles::new(active_paths.clone());
633
634 drop(repo_span);
635 let warm_span = crate::profile::span("setup: warm decision");
636
637 let warm_budget = if warm_detach_enabled() {
644 answer_warm_budget()
645 } else {
646 answer_warm_budget() + deferred_warm_budget()
647 };
648 let was_warming = coverage.as_deref() != Some("complete");
649
650 let indexed_head = (!was_warming)
661 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
662 .flatten();
663 let staleness = (!was_warming && warming_ok && !args.batch)
666 .then(|| root.clone())
667 .flatten()
668 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
669 let want_warm = warming_ok && was_warming && root.is_some();
672
673 let block = want_warm && was_warming && !no_wait;
687 let progress_ui = block && show_progress(out, stderr_interactive());
692 let indexer_budget = if block { wait_budget } else { warm_budget };
693 if progress_ui {
694 install_interrupt_handler();
695 }
696
697 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
700 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
701 crate::trace!(
702 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
703 crate::index::parse_jobs()
704 );
705 let root = root.clone().expect("checked");
706 let active = active_paths.clone();
707 let q = query.to_string();
708 let warm_done = std::sync::Arc::clone(&warm_done);
709 std::thread::spawn(move || {
710 if let Ok(mut idx) = open_store() {
711 let _ = if block {
713 crate::index::index_budgeted_cancellable(
716 &mut idx,
717 &root,
718 &active,
719 indexer_budget,
720 Some(&q),
721 &INTERRUPTED,
722 )
723 } else {
724 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
725 };
726 }
727 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
728 })
729 });
730
731 crate::trace!(
738 "setup (open + repo detect + warm decision): {} ms",
739 t_setup.elapsed().as_millis()
740 );
741 let poll_start = std::time::Instant::now();
742 let deadline = if progress_ui {
746 None
747 } else if block {
748 Some(poll_start + wait_budget)
749 } else {
750 Some(poll_start + answer_warm_budget())
751 };
752 drop(warm_span);
753 let polling = indexer.is_some() && was_warming;
754 drop(setup_span);
758 let mut query_span = crate::profile::span("query");
759 let label = repo_label(root.as_deref());
760 let mut drew_progress = false;
761 let mut last_draw = poll_start;
762 let mut hits = loop {
763 match crate::search::search(store, query, current, only_repo, &active, limit) {
764 Ok(h) => {
765 let confident = h.first().is_some_and(|hit| {
766 hit.features
767 .iter()
768 .any(|f| matches!(f.name, "exact" | "prefix"))
769 });
770 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
771 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
772 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
773 if !polling || confident || warm_finished || stopped || timed_out {
774 break h;
775 }
776 if progress_ui
777 && poll_start.elapsed() >= HEADS_UP_DELAY
778 && last_draw.elapsed() >= PROGRESS_REDRAW
779 {
780 draw_progress(store, identity.as_deref(), &label);
781 drew_progress = true;
782 last_draw = std::time::Instant::now();
783 }
784 }
785 Err(e) => {
786 if let Some(h) = indexer {
787 let _ = h.join();
788 }
789 return fail(format_args!("rq: {e}"));
790 }
791 }
792 std::thread::sleep(POLL_INTERVAL);
793 };
794 query_span.note(|| {
795 if polling {
796 "polled a warming index".to_string()
797 } else {
798 String::new()
799 }
800 });
801 drop(query_span);
802 if drew_progress {
803 clear_progress();
804 }
805 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
807
808 if !hits.is_empty() && revalidate_top(store, &hits) {
810 hits = crate::search::search(store, query, current, only_repo, &active, limit)
811 .unwrap_or_default();
812 }
813
814 if !hits.iter().any(strong)
818 && indexer.is_none()
819 && coverage.is_none()
820 && let Some(root) = &root
821 {
822 let tail = live_fallback(root, query, limit);
823 hits = crate::search::merge(hits, tail, limit);
824 }
825
826 apply_gates(query, &mut hits);
827 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
828
829 if hits.is_empty() {
830 if block {
832 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
833 }
834 if let Some(h) = indexer {
835 let _ = h.join();
836 }
837 let mut incomplete = (block || no_wait)
844 && identity
845 .as_deref()
846 .and_then(|id| store.coverage_status(id).ok().flatten())
847 .as_deref()
848 != Some("complete");
849 incomplete |= settle_warm(
859 store,
860 staleness,
861 was_warming,
862 warming_ok,
863 root.as_deref(),
864 active_paths,
865 query,
866 warm_budget,
867 no_wait,
868 identity.as_deref(),
869 );
870 return no_match_code(out, query, interrupted, incomplete);
871 }
872
873 for hit in &mut hits {
876 hit.signature = read_signature(
877 store,
878 &hit.repo_identity,
879 &hit.file,
880 hit.line,
881 cwd.as_deref(),
882 );
883 }
884 attach_confidence(&mut hits);
885
886 if show
889 && let Some(code) = show_top_definition(
890 store,
891 &mut hits,
892 query,
893 out,
894 cwd.as_deref(),
895 current,
896 no_record,
897 )
898 {
899 return code;
900 }
901
902 if open {
906 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
907 }
908
909 if let Some(code) = render_hits(args, &hits) {
910 return code;
911 }
912
913 if crate::profile::enabled() {
916 let total = profile_started.elapsed();
917 if args.out == Output::Text {
918 for line in crate::profile::report(total) {
919 eprintln!("{line}");
920 }
921 } else {
922 eprintln!("{}", crate::profile::json(total));
925 }
926 }
927
928 if let Some(refresh) = branch_refresh.take() {
935 refresh.store(store);
936 }
937
938 deferred_maintenance(store);
945
946 if block {
951 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
952 }
953 if let Some(h) = indexer {
954 let _ = h.join();
955 }
956 let _ = settle_warm(
957 store,
958 staleness,
959 was_warming,
960 warming_ok,
961 root.as_deref(),
962 active_paths,
963 query,
964 warm_budget,
965 no_wait,
966 identity.as_deref(),
967 );
968
969 ExitCode::SUCCESS
970}
971
972fn maybe_detach_warm(
975 store: &Store,
976 want_warm: bool,
977 changed: bool,
978 root: Option<&std::path::Path>,
979 identity: Option<&str>,
980) {
981 if !warm_detach_enabled() || !want_warm {
982 return;
983 }
984 let (Some(root), Some(id)) = (root, identity) else {
985 return;
986 };
987 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
991 return; }
993 spawn_detached_warm(root);
994}
995
996fn spawn_detached_warm(root: &std::path::Path) {
1000 use std::os::unix::process::CommandExt;
1001 let Ok(exe) = std::env::current_exe() else {
1002 return;
1003 };
1004 let mut cmd = std::process::Command::new(exe);
1005 cmd.arg("--warm")
1006 .arg(root)
1007 .stdin(std::process::Stdio::null())
1008 .stdout(std::process::Stdio::null())
1009 .stderr(std::process::Stdio::null())
1010 .process_group(0);
1011 match cmd.spawn() {
1012 Ok(child) => crate::trace!(
1013 "background warm (detached): pid {} for {}",
1014 child.id(),
1015 crate::trace::abbrev(root)
1016 ),
1017 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1018 }
1019}
1020
1021const WARM_LOCK_TTL_SECS: i64 = 600;
1024
1025fn cmd_warm(path: Option<&str>) -> ExitCode {
1030 #[cfg(target_os = "macos")]
1033 unsafe extern "C" {
1034 fn setiopolicy_np(
1037 iotype: libc::c_int,
1038 scope: libc::c_int,
1039 policy: libc::c_int,
1040 ) -> libc::c_int;
1041 }
1042 unsafe {
1043 libc::nice(10);
1044 #[cfg(target_os = "macos")]
1045 setiopolicy_np(0, 0, 3);
1046 }
1047 let mut store = match open_store() {
1048 Ok(s) => s,
1049 Err(_) => return ExitCode::FAILURE,
1050 };
1051 let start = path
1052 .map(PathBuf::from)
1053 .or_else(|| std::env::current_dir().ok())
1054 .unwrap_or_else(|| PathBuf::from("."));
1055 let root = crate::index::repo_root(&start).unwrap_or(start);
1056 let identity = resolve_identity(&store, &root);
1057
1058 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1061 && pid != std::process::id()
1062 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1063 && now_secs() - ts < WARM_LOCK_TTL_SECS
1064 {
1065 return ExitCode::SUCCESS;
1066 }
1067 let _ = store.set_warm_lock(&identity, std::process::id());
1068
1069 let deadline = std::time::Instant::now() + warm_bg_budget();
1072 let active = crate::index::branch_changed_files(&root);
1073 loop {
1074 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1075 if remaining.is_zero() {
1076 break;
1077 }
1078 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1079 {
1080 Ok(s) => s,
1081 Err(_) => break,
1082 };
1083 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1084 || stats.files_indexed == 0
1085 {
1086 break;
1087 }
1088 }
1089 let _ = store.clear_warm_lock(&identity);
1090 ExitCode::SUCCESS
1091}
1092
1093fn now_secs() -> i64 {
1094 std::time::SystemTime::now()
1095 .duration_since(std::time::UNIX_EPOCH)
1096 .map(|d| d.as_secs() as i64)
1097 .unwrap_or(0)
1098}
1099
1100fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1103 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1104 let deadline = std::time::Instant::now() + live_fallback_budget();
1105 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1106 if !h.is_empty() {
1107 return h;
1108 }
1109 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1110}
1111
1112fn strong(h: &crate::search::Hit) -> bool {
1114 h.features
1115 .iter()
1116 .any(|f| matches!(f.name, "exact" | "prefix"))
1117}
1118
1119fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1128 if hits.iter().any(strong) {
1129 hits.retain(strong);
1130 }
1131 crate::search::apply_scope_gate(query, hits);
1132}
1133
1134fn apply_post_filters(
1137 args: &SearchArgs,
1138 cwd: Option<&std::path::Path>,
1139 root: Option<&std::path::Path>,
1140 hits: &mut Vec<crate::search::Hit>,
1141) {
1142 if !args.paths.is_empty() {
1143 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1147 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1148 let norm: Vec<String> = args
1149 .paths
1150 .iter()
1151 .map(|p| repo_relative(&base, &here, p))
1152 .collect();
1153 hits.retain(|h| under_any(&h.file, &norm));
1154 }
1155 if !args.kinds.is_empty() {
1156 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1157 }
1158 if !args.langs.is_empty() {
1159 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1160 }
1161 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1162 hits.truncate(args.want);
1163 }
1164}
1165
1166fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
1172 let status = if interrupted {
1173 "interrupted"
1174 } else if incomplete {
1175 "warming"
1176 } else {
1177 "no_match"
1178 };
1179 match out {
1180 Output::Json | Output::Ndjson => {
1181 let obj = serde_json::json!({ "status": status, "query": query });
1182 let _ = emit_json(out, &obj); }
1184 Output::Text if interrupted => {
1185 eprintln!("rq: indexing interrupted — run again to finish")
1186 }
1187 Output::Text if incomplete => eprintln!(
1188 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1189 ),
1190 Output::Text => eprintln!("no matches for {query:?}"),
1191 }
1192 if incomplete {
1193 ExitCode::from(2)
1194 } else {
1195 ExitCode::FAILURE
1196 }
1197}
1198
1199fn attach_confidence(hits: &mut [crate::search::Hit]) {
1203 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1204 if t.is_none_or(|t| h.score > t) {
1205 (Some(h.score), t)
1206 } else if s.is_none_or(|s| h.score > s) {
1207 (t, Some(h.score))
1208 } else {
1209 (t, s)
1210 }
1211 });
1212 for hit in hits.iter_mut() {
1213 let best_other = if Some(hit.score) == top { second } else { top };
1214 hit.confidence = crate::search::confidence(
1215 hit.score,
1216 crate::search::match_quality(&hit.features),
1217 best_other,
1218 );
1219 }
1220}
1221
1222fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1225 let render_span = crate::profile::span("render");
1229 if args.batch {
1230 #[derive(serde::Serialize)]
1233 struct Tagged<'a> {
1234 query: &'a str,
1235 #[serde(flatten)]
1236 hit: &'a crate::search::Hit,
1237 }
1238 let rows: Vec<Tagged> = hits
1239 .iter()
1240 .map(|hit| Tagged {
1241 query: args.query,
1242 hit,
1243 })
1244 .collect();
1245 if let Some(code) = emit_rows(args.out, &rows) {
1246 return Some(code);
1247 }
1248 } else if let Some(code) = emit_rows(args.out, hits) {
1249 return Some(code);
1250 }
1251 if args.out != Output::Text {
1252 return None;
1253 }
1254 drop(render_span);
1255 let color = match_color();
1256 let c = color.as_deref();
1257 let query = args.query;
1258 if args.show {
1259 eprintln!(
1261 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1262 hits.len()
1263 );
1264 }
1265 for hit in hits {
1266 let name = hl(&hit.name, query, c);
1269 let qualified = match &hit.parent {
1270 Some(p) => format!("{name} · {p}"),
1271 None => name,
1272 };
1273 println!(
1274 "{}:{} {} {}",
1275 hl_path(&hit.file, query, c),
1276 hit.line,
1277 hit.kind,
1278 qualified
1279 );
1280 if let Some(sig) = &hit.signature {
1281 println!(" {}", hl(sig, query, c));
1282 }
1283 if args.explain {
1284 let parts: Vec<String> = hit
1285 .features
1286 .iter()
1287 .map(|f| format!("{} {:.0}", f.name, f.value))
1288 .collect();
1289 println!(
1290 " confidence {:.2} · score {:.0} = {}",
1291 hit.confidence,
1292 hit.score,
1293 parts.join(" + ")
1294 );
1295 }
1296 }
1297 None
1298}
1299
1300fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1304 use std::io::{IsTerminal, Write};
1305 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1306 return hits.first();
1307 }
1308 let mut err = std::io::stderr();
1309 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1310 for (i, h) in hits.iter().enumerate() {
1311 let _ = writeln!(
1312 err,
1313 " {}. {}:{} {} {}",
1314 i + 1,
1315 h.file,
1316 h.line,
1317 h.kind,
1318 h.name
1319 );
1320 }
1321 let _ = write!(err, "rq> ");
1322 let _ = err.flush();
1323 let mut line = String::new();
1324 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1325 return None; }
1327 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1328}
1329
1330fn parse_choice(input: &str, n: usize) -> Option<usize> {
1333 let s = input.trim();
1334 if s.is_empty() {
1335 return Some(0);
1336 }
1337 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1338 (i < n).then_some(i)
1339}
1340
1341fn finish_open(
1345 store: &mut Store,
1346 hits: &[crate::search::Hit],
1347 query: &str,
1348 current: Option<i64>,
1349 root: Option<&std::path::Path>,
1350 no_record: bool,
1351) -> ExitCode {
1352 let Some(hit) = choose_hit(hits) else {
1353 return ExitCode::SUCCESS; };
1355
1356 if !no_record {
1359 let _ = store.record_event(
1360 "select",
1361 Some(&query.to_ascii_lowercase()),
1362 current,
1363 Some(&hit.file),
1364 Some(hit.line),
1365 None,
1366 );
1367 deferred_maintenance(store);
1368 }
1369
1370 let target = match root {
1373 Some(r) => r.join(&hit.file),
1374 None => PathBuf::from(&hit.file),
1375 };
1376 launch_editor(&target, hit.line)
1377}
1378
1379fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1383 use std::os::unix::process::CommandExt;
1384 let loc = format!("{}:{}", file.display(), line);
1385 match open_command(file, line, &loc) {
1386 Some((prog, args)) => {
1387 let err = std::process::Command::new(&prog).args(&args).exec();
1389 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1390 }
1391 None => {
1392 println!("{loc}");
1393 ExitCode::SUCCESS
1394 }
1395 }
1396}
1397
1398fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1402 let fstr = file.to_string_lossy().into_owned();
1403
1404 if let Some(t) = std::env::var_os("RQ_OPEN") {
1405 let t = t.to_string_lossy();
1406 let mut parts = t.split_whitespace().map(|p| {
1407 p.replace("{file}", &fstr)
1408 .replace("{line}", &line.to_string())
1409 .replace("{}", loc)
1410 });
1411 if let Some(prog) = parts.next() {
1412 return Some((prog, parts.collect()));
1413 }
1414 }
1415
1416 if on_path("code") {
1417 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1418 }
1419
1420 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1421 let ed = ed.to_string_lossy().into_owned();
1422 let l = ed.to_ascii_lowercase();
1423 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1425 .iter()
1426 .any(|e| l.contains(e))
1427 {
1428 return Some((ed, vec![format!("+{line}"), fstr]));
1429 }
1430 return Some((ed, vec![fstr]));
1431 }
1432
1433 None
1434}
1435
1436fn on_path(prog: &str) -> bool {
1438 std::env::var_os("PATH")
1439 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1440}
1441
1442fn unix_now() -> i64 {
1452 std::time::SystemTime::now()
1453 .duration_since(std::time::UNIX_EPOCH)
1454 .map(|d| d.as_secs() as i64)
1455 .unwrap_or(0)
1456}
1457
1458const BRANCH_FILES_TTL_SECS: i64 = 15;
1464
1465struct BranchRefresh {
1469 handle: std::thread::JoinHandle<Vec<String>>,
1470 identity: String,
1471 stamp: String,
1472}
1473
1474impl BranchRefresh {
1475 fn store(self, store: &Store) {
1477 let Ok(files) = self.handle.join() else {
1478 return;
1479 };
1480 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1481 }
1482}
1483
1484fn cached_branch_files(
1498 store: &Store,
1499 root: &std::path::Path,
1500) -> (Vec<String>, Option<BranchRefresh>) {
1501 let identity = resolve_identity(store, root);
1502 let stamp = crate::index::branch_files_stamp(root);
1503 let cached = store.branch_files_get(&identity).ok().flatten();
1504 let now = unix_now();
1505
1506 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1507 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1508 return (files.clone(), None);
1509 }
1510 let owned_root = root.to_path_buf();
1511 let refresh = BranchRefresh {
1512 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1513 identity,
1514 stamp: stamp.clone(),
1515 };
1516 return (files.clone(), Some(refresh));
1517 }
1518
1519 let files = crate::index::branch_changed_files(root);
1521 if let Some(stamp) = stamp {
1522 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1523 }
1524 (files, None)
1525}
1526
1527fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1536 let Some(head) = indexed_head else {
1537 return true;
1538 };
1539 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1540}
1541
1542#[allow(clippy::too_many_arguments)]
1550fn settle_warm(
1551 store: &Store,
1552 staleness: Option<std::thread::JoinHandle<bool>>,
1553 was_warming: bool,
1554 warming_ok: bool,
1555 root: Option<&std::path::Path>,
1556 active: &[String],
1557 query: &str,
1558 budget: Duration,
1559 no_wait: bool,
1560 identity: Option<&str>,
1561) -> bool {
1562 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1565 if changed
1575 && !no_wait
1576 && !warm_detach_enabled()
1577 && let Some(r) = root
1578 && let Ok(mut idx) = open_store()
1579 {
1580 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1581 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1582 }
1583 maybe_detach_warm(
1584 store,
1585 warming_ok && (was_warming || changed),
1586 changed,
1587 root,
1588 identity,
1589 );
1590 changed && warm_detach_enabled()
1594}
1595
1596fn answer_warm_budget() -> Duration {
1605 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1606}
1607
1608fn deferred_warm_budget() -> Duration {
1611 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1612}
1613
1614fn live_fallback_budget() -> Duration {
1617 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1618}
1619
1620fn warm_bg_budget() -> Duration {
1623 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1624}
1625
1626fn warm_detach_enabled() -> bool {
1630 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1631}
1632
1633fn wait_budget() -> Duration {
1642 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1643}
1644
1645fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1650 let s = s.trim();
1651 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1652 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1654 (n, 1.0)
1655 } else if let Some(n) = s.strip_suffix('s') {
1656 (n, 1_000.0)
1657 } else if let Some(n) = s.strip_suffix('m') {
1658 (n, 60_000.0)
1659 } else {
1660 (s, 1_000.0)
1662 };
1663 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1664 if !val.is_finite() || val < 0.0 {
1665 return Err(bad());
1666 }
1667 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1668}
1669
1670static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1674
1675extern "C" fn on_sigint(_: libc::c_int) {
1676 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1678}
1679
1680fn install_interrupt_handler() {
1683 static ONCE: std::sync::Once = std::sync::Once::new();
1684 ONCE.call_once(|| unsafe {
1685 let mut action: libc::sigaction = std::mem::zeroed();
1686 action.sa_sigaction = on_sigint as *const () as usize;
1687 libc::sigemptyset(&mut action.sa_mask);
1688 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1689 });
1690}
1691
1692fn stderr_interactive() -> bool {
1696 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1697}
1698
1699fn show_progress(out: Output, interactive: bool) -> bool {
1704 interactive && matches!(out, Output::Text)
1705}
1706
1707fn repo_label(root: Option<&std::path::Path>) -> String {
1710 root.and_then(|r| r.file_name())
1711 .map(|n| n.to_string_lossy().into_owned())
1712 .unwrap_or_else(|| "repo".into())
1713}
1714
1715fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1719 let files = identity
1720 .and_then(|id| store.repository_id(id).ok().flatten())
1721 .and_then(|rid| store.repo_totals(rid).ok())
1722 .map_or(0, |(f, _)| f);
1723 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1724 let _ = std::io::stderr().flush();
1725}
1726
1727fn clear_progress() {
1729 eprint!("\r\x1b[K");
1730 let _ = std::io::stderr().flush();
1731}
1732
1733fn env_budget(var: &str, default_ms: u64) -> Duration {
1737 let ms = std::env::var(var)
1738 .ok()
1739 .and_then(|v| v.parse().ok())
1740 .unwrap_or(default_ms);
1741 Duration::from_millis(ms)
1742}
1743
1744const AGGREGATE_BATCH: usize = 256;
1747
1748const KEEP_RECENT_EVENTS: i64 = 200;
1751
1752fn deferred_maintenance(store: &mut Store) {
1755 let _ = store.aggregate_events(AGGREGATE_BATCH);
1756 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1757}
1758
1759fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1762 let mut store = match open_store() {
1763 Ok(s) => s,
1764 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1765 };
1766 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1767 let identity = crate::index::detect_identity(&cwd).to_string();
1768 let repo_id = store.repository_id(&identity).ok().flatten();
1769
1770 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1773 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1774 None => file.to_string(),
1775 };
1776 let query_norm = query.map(|q| q.to_ascii_lowercase());
1777
1778 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1779 {
1780 return fail(format_args!("rq record: {e}"));
1781 }
1782 deferred_maintenance(&mut store);
1783 ExitCode::SUCCESS
1784}
1785
1786fn hit_file_roots(
1792 store: &Store,
1793 repo_identity: &str,
1794 cwd: Option<&std::path::Path>,
1795) -> Vec<PathBuf> {
1796 let mut roots: Vec<PathBuf> = store
1797 .repository_id(repo_identity)
1798 .ok()
1799 .flatten()
1800 .map(|id| store.checkout_roots(id).unwrap_or_default())
1801 .unwrap_or_default()
1802 .into_iter()
1803 .map(PathBuf::from)
1804 .collect();
1805 if let Some(c) = cwd {
1806 let c = c.to_path_buf();
1807 if !roots.contains(&c) {
1808 roots.push(c);
1809 }
1810 }
1811 roots
1812}
1813
1814fn read_signature(
1817 store: &Store,
1818 repo_identity: &str,
1819 file: &str,
1820 line: i64,
1821 cwd: Option<&std::path::Path>,
1822) -> Option<String> {
1823 hit_file_roots(store, repo_identity, cwd)
1824 .into_iter()
1825 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1826}
1827
1828const SHOW_CONFIDENCE: f64 = 0.85;
1832
1833fn show_top_definition(
1842 store: &mut Store,
1843 hits: &mut [crate::search::Hit],
1844 query: &str,
1845 out: Output,
1846 cwd: Option<&std::path::Path>,
1847 current: Option<i64>,
1848 no_record: bool,
1849) -> Option<ExitCode> {
1850 let top = hits.first()?;
1851 if top.confidence < SHOW_CONFIDENCE {
1852 return None; }
1854 let end = top.end_line.unwrap_or(top.line);
1855 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1856 hits[0].body = body;
1857 let top = &hits[0];
1858 let shown = (top.file.clone(), top.line);
1859 let code = match out {
1860 Output::Json | Output::Ndjson => {
1861 emit_json(out, top)
1863 }
1864 Output::Text => {
1865 let color = match_color();
1866 let c = color.as_deref();
1867 let name = hl(&top.name, query, c);
1868 let qualified = match &top.parent {
1869 Some(p) => format!("{name} · {p}"),
1870 None => name,
1871 };
1872 println!(
1873 "{}:{} {} {}",
1874 hl_path(&top.file, query, c),
1875 top.line,
1876 top.kind,
1877 qualified
1878 );
1879 match (&top.body, &top.signature) {
1880 (Some(body), _) => println!("{body}"),
1881 (None, Some(sig)) => println!("{sig}"),
1883 (None, None) => {}
1884 }
1885 ExitCode::SUCCESS
1886 }
1887 };
1888
1889 if !no_record {
1891 let (file, line) = shown;
1892 let _ = store.record_event(
1893 "select",
1894 Some(&query.to_ascii_lowercase()),
1895 current,
1896 Some(&file),
1897 Some(line),
1898 None,
1899 );
1900 deferred_maintenance(store);
1901 }
1902 Some(code)
1903}
1904
1905fn read_span(
1908 store: &Store,
1909 repo_identity: &str,
1910 file: &str,
1911 start: i64,
1912 end: i64,
1913 cwd: Option<&std::path::Path>,
1914) -> Option<String> {
1915 hit_file_roots(store, repo_identity, cwd)
1916 .into_iter()
1917 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1918}
1919
1920fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1923 let s = usize::try_from(start).ok()?.checked_sub(1)?;
1924 let lines: Vec<&str> = content.lines().collect();
1925 if s >= lines.len() {
1926 return None;
1927 }
1928 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1929 Some(lines[s..e].join("\n"))
1930}
1931
1932fn signature_in(content: &str, line: i64) -> Option<String> {
1936 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1937 let l = content.lines().nth(idx)?.trim();
1938 (!l.is_empty()).then(|| l.to_string())
1939}
1940
1941#[derive(serde::Serialize)]
1945struct SymbolOut {
1946 name: String,
1947 kind: String,
1948 language: String,
1949 file: String,
1950 line: i64,
1951 #[serde(skip_serializing_if = "Option::is_none")]
1952 end_line: Option<i64>,
1953 #[serde(skip_serializing_if = "Option::is_none")]
1954 parent: Option<String>,
1955 #[serde(skip_serializing_if = "Option::is_none")]
1956 visibility: Option<String>,
1957 repo: String,
1958 #[serde(skip_serializing_if = "Option::is_none")]
1959 signature: Option<String>,
1960}
1961
1962fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
1967 let mut store = match open_store() {
1968 Ok(s) => s,
1969 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1970 };
1971 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1972 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
1973 let rel = repo_relative(&root, &cwd, file_arg);
1974
1975 let identity = resolve_identity(&store, &root);
1976 let coverage = store.coverage_status(&identity).ok().flatten();
1977 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
1978 let current = store.repository_id(&identity).ok().flatten();
1979 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
1982 let needs_warm = warming_ok
1983 && (coverage.as_deref() != Some("complete")
1984 || worktree_changed(&root, indexed_head.as_deref()));
1985 if needs_warm {
1986 let budget = answer_warm_budget() + deferred_warm_budget();
1988 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
1989 }
1990
1991 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
1992 return emit_symbols(out, &[]); };
1994 let mut rows = match store.symbols_in_file(repo_id, &rel) {
1995 Ok(r) => r,
1996 Err(e) => return fail(format_args!("rq: {e}")),
1997 };
1998 if !kinds.is_empty() {
1999 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2000 }
2001 if !langs.is_empty() {
2002 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2003 }
2004
2005 let content = hit_file_roots(&store, &identity, Some(&root))
2009 .iter()
2010 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
2011 let syms: Vec<SymbolOut> = rows
2012 .into_iter()
2013 .map(|r| SymbolOut {
2014 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
2015 name: r.name,
2016 kind: r.kind,
2017 language: r.language,
2018 file: r.file,
2019 line: r.line,
2020 end_line: r.end_line,
2021 parent: r.parent,
2022 visibility: r.visibility,
2023 repo: r.repo_identity,
2024 })
2025 .collect();
2026 emit_symbols(out, &syms)
2027}
2028
2029fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2032 if syms.is_empty() {
2033 match out {
2034 Output::Json | Output::Ndjson => {
2035 let obj = serde_json::json!({ "status": "no_match" });
2036 let _ = emit_json(out, &obj); }
2038 Output::Text => eprintln!("no symbols"),
2039 }
2040 return ExitCode::FAILURE;
2041 }
2042 if let Some(code) = emit_rows(out, syms) {
2043 return code;
2044 }
2045 match out {
2046 Output::Json | Output::Ndjson => {}
2047 Output::Text => {
2048 for s in syms {
2049 let qualified = match &s.parent {
2050 Some(p) => format!("{} · {p}", s.name),
2051 None => s.name.clone(),
2052 };
2053 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2054 if let Some(sig) = &s.signature {
2055 println!(" {sig}");
2056 }
2057 }
2058 }
2059 }
2060 ExitCode::SUCCESS
2061}
2062
2063fn keyword_kind(token: &str) -> Option<&'static str> {
2068 match token.to_ascii_lowercase().as_str() {
2069 "class" => Some("class"),
2070 "module" => Some("module"),
2071 "method" => Some("method"),
2072 "function" | "fn" => Some("function"),
2073 "struct" | "type" => Some("struct"),
2074 "enum" => Some("enum"),
2075 "trait" | "interface" => Some("trait"),
2076 _ => None,
2077 }
2078}
2079
2080fn split_kind_keyword(
2086 target: String,
2087 dirs: Vec<String>,
2088) -> (Option<&'static str>, String, Vec<String>) {
2089 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2092 let rest = rest.trim();
2093 if let Some(k) = keyword_kind(head)
2094 && !rest.is_empty()
2095 {
2096 return (Some(k), rest.to_string(), dirs);
2097 }
2098 } else if let Some(k) = keyword_kind(&target)
2099 && let Some((query, extra)) = dirs.split_first()
2100 {
2101 return (Some(k), query.clone(), extra.to_vec());
2103 }
2104 (None, target, dirs)
2105}
2106
2107fn canonical_kind(s: &str) -> String {
2110 match s.to_ascii_lowercase().as_str() {
2111 "c" | "class" => "class",
2112 "m" | "method" => "method",
2113 "f" | "fn" | "func" | "function" => "function",
2114 "mod" | "module" => "module",
2115 "s" | "struct" | "type" => "struct",
2116 "e" | "enum" => "enum",
2117 "t" | "trait" | "interface" => "trait",
2118 other => return other.to_string(),
2119 }
2120 .to_string()
2121}
2122
2123fn canonical_langs(s: &str) -> Vec<String> {
2130 let t = s.to_ascii_lowercase();
2131 let alias = match t.as_str() {
2132 "rb" => Some("ruby"),
2133 "rs" => Some("rust"),
2134 "golang" => Some("go"),
2135 "ts" | "tsx" => Some("typescript"),
2136 "js" | "jsx" => Some("javascript"),
2137 _ => None,
2138 };
2139 let matched: Vec<String> = crate::lang::languages()
2140 .into_iter()
2141 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2142 .map(str::to_string)
2143 .collect();
2144 if matched.is_empty() { vec![t] } else { matched }
2145}
2146
2147fn match_color() -> Option<String> {
2151 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2152 return None;
2153 }
2154 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2155 gc.split(':').find_map(|e| {
2156 e.strip_prefix("mt=")
2157 .or_else(|| e.strip_prefix("ms="))
2158 .filter(|v| !v.is_empty())
2159 .map(str::to_string)
2160 })
2161 });
2162 Some(style.unwrap_or_else(|| "1;31".to_string()))
2163}
2164
2165fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2168 match color {
2169 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2170 None => text.to_string(),
2171 }
2172}
2173
2174fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2177 let Some(c) = color else {
2178 return path.to_string();
2179 };
2180 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2181 let base_start = path[..base_byte].chars().count();
2182 let stem = crate::search::path_stem(path);
2186 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2187 .into_iter()
2188 .map(|p| p + base_start)
2189 .collect();
2190 highlight(path, &positions, c)
2191}
2192
2193fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2196 if positions.is_empty() {
2197 return text.to_string();
2198 }
2199 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2200 let mut out = String::new();
2201 let mut on = false;
2202 for (i, c) in text.chars().enumerate() {
2203 match (matched.contains(&i), on) {
2204 (true, false) => {
2205 out.push_str("\x1b[");
2206 out.push_str(color);
2207 out.push('m');
2208 on = true;
2209 }
2210 (false, true) => {
2211 out.push_str("\x1b[0m");
2212 on = false;
2213 }
2214 _ => {}
2215 }
2216 out.push(c);
2217 }
2218 if on {
2219 out.push_str("\x1b[0m");
2220 }
2221 out
2222}
2223
2224fn under_any(file: &str, paths: &[String]) -> bool {
2228 paths.iter().any(|p| {
2229 let p = p.trim_start_matches("./").trim_end_matches('/');
2230 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2231 })
2232}
2233
2234fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2236 let p = std::path::Path::new(file);
2237 let abs = if p.is_absolute() {
2238 p.to_path_buf()
2239 } else {
2240 cwd.join(p)
2241 };
2242 let abs = abs.canonicalize().unwrap_or(abs);
2243 abs.strip_prefix(root)
2244 .map(|r| r.to_string_lossy().into_owned())
2245 .unwrap_or_else(|_| file.to_string())
2246}
2247
2248fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2252 use std::collections::HashSet;
2253 let mut seen = HashSet::new();
2254 let mut changed = false;
2255 for hit in hits {
2256 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2257 continue;
2258 }
2259 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2260 continue;
2261 };
2262 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2263 continue;
2264 };
2265 if let Ok(crate::index::Refresh::Updated) =
2266 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2267 {
2268 changed = true;
2269 }
2270 }
2271 changed
2272}
2273
2274fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2280 if let Ok(canon) = cwd.canonicalize() {
2281 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2282 return identity;
2283 }
2284 if crate::index::repo_root(cwd).is_none() {
2285 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2286 }
2287 }
2288 crate::index::detect_identity(cwd).to_string()
2289}
2290
2291fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2292 let explicit = path.is_some();
2293 let target = path.unwrap_or_else(|| PathBuf::from("."));
2294 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2299 let mut subdirs = subdirs.to_vec();
2304 if explicit
2305 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2306 && t != r
2307 && let Ok(rel) = t.strip_prefix(&r)
2308 && !rel.as_os_str().is_empty()
2309 {
2310 subdirs.push(rel.to_string_lossy().into_owned());
2311 }
2312 let mut store = match open_store() {
2313 Ok(s) => s,
2314 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2315 };
2316 let identity = crate::index::detect_identity(&root).to_string();
2317 match crate::index::index_under(&mut store, &root, &subdirs) {
2318 Ok(stats) => {
2319 let subtree = !subdirs.is_empty();
2320 let totals = store
2322 .repository_id(&identity)
2323 .ok()
2324 .flatten()
2325 .and_then(|id| store.repo_totals(id).ok());
2326 match out {
2327 Output::Json | Output::Ndjson => {
2328 let (files, symbols) = match totals {
2329 Some((f, s)) => (Some(f), Some(s)),
2330 None => (None, None),
2331 };
2332 return emit_json(
2333 out,
2334 &serde_json::json!({
2335 "repo": identity,
2336 "scope": if subtree { "subtree" } else { "full" },
2337 "files_added": stats.files_indexed,
2338 "symbols_added": stats.symbols,
2339 "files": files,
2340 "symbols": symbols,
2341 }),
2342 );
2343 }
2344 Output::Text => {
2345 let scope = if subtree { " (subtree seed)" } else { "" };
2346 match totals {
2347 Some((files, symbols)) => println!(
2348 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2349 stats.files_indexed, stats.symbols
2350 ),
2351 None => println!(
2352 "{} file(s)/{} symbol(s) added this run{scope}",
2353 stats.files_indexed, stats.symbols
2354 ),
2355 }
2356 }
2357 }
2358 ExitCode::SUCCESS
2359 }
2360 Err(e) => fail(format_args!("rq --index: {e}")),
2361 }
2362}
2363
2364fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2365 let mut store = match open_store() {
2366 Ok(s) => s,
2367 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2368 };
2369
2370 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2374 let root = crate::index::repo_root(&path).unwrap_or(path);
2375 let from_path = crate::index::detect_identity(&root).to_string();
2376 let resolved = match store.repository_id(&from_path) {
2377 Ok(Some(id)) => Some((from_path.clone(), id)),
2378 Ok(None) => target.as_deref().and_then(|s| {
2379 store
2380 .repository_id(s)
2381 .ok()
2382 .flatten()
2383 .map(|id| (s.to_string(), id))
2384 }),
2385 Err(e) => return fail(format_args!("rq --drop: {e}")),
2386 };
2387
2388 let Some((identity, repo_id)) = resolved else {
2389 return match out {
2391 Output::Text => {
2392 println!("not indexed: {from_path}");
2393 ExitCode::SUCCESS
2394 }
2395 _ => emit_json(
2396 out,
2397 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2398 ),
2399 };
2400 };
2401
2402 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2403 match store.drop_repository(repo_id) {
2404 Ok(()) => match out {
2405 Output::Text => {
2406 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2407 ExitCode::SUCCESS
2408 }
2409 _ => emit_json(
2410 out,
2411 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2412 ),
2413 },
2414 Err(e) => fail(format_args!("rq --drop: {e}")),
2415 }
2416}
2417
2418fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2422 let rendered = if out == Output::Json {
2423 serde_json::to_string_pretty(value)
2424 } else {
2425 serde_json::to_string(value)
2426 };
2427 match rendered {
2428 Ok(s) => {
2429 println!("{s}");
2430 ExitCode::SUCCESS
2431 }
2432 Err(e) => fail(format_args!("rq: {e}")),
2433 }
2434}
2435
2436fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2440 match out {
2441 Output::Json => match serde_json::to_string_pretty(rows) {
2442 Ok(s) => println!("{s}"),
2443 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2444 },
2445 Output::Ndjson => {
2446 for r in rows {
2447 match serde_json::to_string(r) {
2448 Ok(line) => println!("{line}"),
2449 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2450 }
2451 }
2452 }
2453 Output::Text => {}
2454 }
2455 None
2456}
2457
2458fn cmd_status(out: Output) -> ExitCode {
2459 let store = match open_store() {
2460 Ok(s) => s,
2461 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2462 };
2463 let rows = match store.coverage_overview() {
2464 Ok(rows) => rows,
2465 Err(e) => return fail(format_args!("rq --status: {e}")),
2466 };
2467 if let Some(code) = emit_rows(out, &rows) {
2468 return code;
2469 }
2470 match out {
2471 Output::Json | Output::Ndjson => {}
2472 Output::Text if rows.is_empty() => {
2473 println!("no repositories indexed yet (try `rq --index`)");
2474 }
2475 Output::Text => {
2476 for r in &rows {
2477 println!(
2478 "{:<10} {:>6} files {:>7} symbols {}",
2479 r.status, r.files, r.symbols, r.identity
2480 );
2481 }
2482 }
2483 }
2484 ExitCode::SUCCESS
2485}
2486
2487fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2489 let path = db_path()?;
2490 if let Some(parent) = path.parent() {
2491 std::fs::create_dir_all(parent)?;
2492 }
2493 Ok(Store::open(&path)?)
2494}
2495
2496fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2498 if let Ok(p) = std::env::var("RQ_DB") {
2499 return Ok(PathBuf::from(p));
2500 }
2501 let home = std::env::var("HOME")?;
2502 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2503}
2504
2505fn fail(args: std::fmt::Arguments) -> ExitCode {
2506 eprintln!("{args}");
2507 ExitCode::FAILURE
2508}
2509
2510#[cfg(test)]
2511mod tests {
2512 use super::*;
2513
2514 #[test]
2515 fn open_menu_choice_parsing() {
2516 assert_eq!(parse_choice("\n", 5), Some(0));
2518 assert_eq!(parse_choice(" ", 5), Some(0));
2519 assert_eq!(parse_choice("3", 5), Some(2));
2520 assert_eq!(parse_choice("5", 5), Some(4));
2521 assert_eq!(parse_choice("6", 5), None);
2523 assert_eq!(parse_choice("0", 5), None);
2524 assert_eq!(parse_choice("q", 5), None);
2525 }
2526
2527 #[test]
2528 fn wait_duration_parsing() {
2529 use std::time::Duration;
2530 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2532 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2533 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2534 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2535 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2537 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2538 assert!(parse_wait("0s").unwrap().is_zero());
2539 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2541 assert!(parse_wait("2x").is_err());
2543 assert!(parse_wait("").is_err());
2544 assert!(parse_wait("s").is_err());
2545 assert!(parse_wait("-1s").is_err());
2546 }
2547
2548 #[test]
2549 fn leading_kind_keyword_becomes_a_kind_filter() {
2550 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2551 assert_eq!(
2553 split_kind_keyword("class".into(), d(&["Widget"])),
2554 (Some("class"), "Widget".into(), vec![])
2555 );
2556 assert_eq!(
2558 split_kind_keyword("method zoom".into(), vec![]),
2559 (Some("method"), "zoom".into(), vec![])
2560 );
2561 assert_eq!(
2563 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2564 (Some("function"), "Foo::run".into(), vec![])
2565 );
2566 assert_eq!(
2568 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2569 (Some("struct"), "Gadget".into(), d(&["src"]))
2570 );
2571 }
2572
2573 #[test]
2574 fn a_bare_or_non_keyword_query_is_left_alone() {
2575 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2576 assert_eq!(
2578 split_kind_keyword("class".into(), vec![]),
2579 (None, "class".into(), vec![])
2580 );
2581 assert_eq!(
2583 split_kind_keyword("Widget".into(), d(&["app"])),
2584 (None, "Widget".into(), d(&["app"]))
2585 );
2586 assert_eq!(
2588 split_kind_keyword("c".into(), d(&["Foo"])),
2589 (None, "c".into(), d(&["Foo"]))
2590 );
2591 }
2592
2593 #[test]
2594 fn a_language_selects_by_prefix_or_alias() {
2595 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2597 assert_eq!(canonical_langs("t"), ["typescript"]);
2598 assert_eq!(canonical_langs("ts"), ["typescript"]);
2600 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2601 assert_eq!(canonical_langs("rb"), ["ruby"]);
2602 assert_eq!(canonical_langs("COBOL"), ["cobol"]);
2604 }
2605
2606 #[test]
2607 fn a_kind_normalizes_language_specific_spellings() {
2608 assert_eq!(canonical_kind("f"), "function");
2609 assert_eq!(canonical_kind("interface"), "trait");
2611 assert_eq!(canonical_kind("type"), "struct");
2612 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2614 assert_eq!(
2615 split_kind_keyword("interface".into(), d(&["Renderer"])),
2616 (Some("trait"), "Renderer".into(), vec![])
2617 );
2618 }
2619
2620 #[test]
2621 fn highlight_wraps_matched_runs() {
2622 assert_eq!(
2623 highlight("FooThing", &[0, 1, 2], "1;31"),
2624 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2625 );
2626 assert_eq!(
2628 highlight("FooThing", &[0, 3], "1"),
2629 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2630 );
2631 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2633 }
2634
2635 #[test]
2636 fn progress_ui_only_for_an_interactive_text_terminal() {
2637 assert!(show_progress(Output::Text, true));
2639
2640 assert!(!show_progress(Output::Json, true));
2642 assert!(!show_progress(Output::Ndjson, true));
2643
2644 assert!(!show_progress(Output::Text, false));
2646 }
2647
2648 #[test]
2649 fn repo_label_uses_the_directory_name() {
2650 assert_eq!(
2651 repo_label(Some(std::path::Path::new("/src/widgets"))),
2652 "widgets"
2653 );
2654 assert_eq!(repo_label(None), "repo");
2655 }
2656
2657 #[test]
2658 fn hl_path_highlights_the_stem_not_the_extension() {
2659 let out = hl_path(
2662 "app/employees_controller.rb",
2663 "employeescontroller",
2664 Some("1;31"),
2665 );
2666 assert!(
2667 out.starts_with("app/\u{1b}[1;31memployees"),
2668 "stem highlighted: {out:?}"
2669 );
2670 assert!(
2671 out.ends_with("controller\u{1b}[0m.rb"),
2672 "`.rb` left un-highlighted: {out:?}"
2673 );
2674 }
2675}